Un downloader video HD completo, veloce e gratuito. Supporta il download di video da YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (precedentemente Twitter) e altri. Goditi download fluidi e di alta qualità senza filigrane.
// ==UserScript==
// @name OmniDown
// @name:ar OmniDown
// @name:bg OmniDown
// @name:cs OmniDown
// @name:da OmniDown
// @name:de OmniDown
// @name:el OmniDown
// @name:en OmniDown
// @name:eo OmniDown
// @name:es OmniDown
// @name:es-419 OmniDown
// @name:fi OmniDown
// @name:fr OmniDown
// @name:fr-CA OmniDown
// @name:he OmniDown
// @name:hr OmniDown
// @name:hu OmniDown
// @name:id OmniDown
// @name:it OmniDown
// @name:ja OmniDown
// @name:ka OmniDown
// @name:ko OmniDown
// @name:nb OmniDown
// @name:nl OmniDown
// @name:pl OmniDown
// @name:pt-BR OmniDown
// @name:ro OmniDown
// @name:ru OmniDown
// @name:sv OmniDown
// @name:th OmniDown
// @name:tr OmniDown
// @name:uk OmniDown
// @name:ug OmniDown
// @name:vi OmniDown
// @name:zh-CN OmniDown
// @name:zh-TW OmniDown
// @description An all-in-one, fast, and free HD video downloader. Supports video downloads from YouTube, TikTok, Douyin, Instagram, Threads, XiaoHongShu, TED, Facebook, X (formerly Twitter), and more. Enjoy seamless, high-quality downloads with no watermarks.
// @description:ar أداة شاملة وسريعة ومجانية لتحميل مقاطع الفيديو بدقة عالية. يدعم تحميل مقاطع الفيديو من يوتيوب، تيك توك، Douyin، إنستغرام، Facebook، ثريدز، XiaoHongShu، TED، إكس (تويتر سابقًا)، والمزيد. استمتع بتحميلات عالية الجودة بدون علامات مائية.
// @description:bg Всичко в едно, бързо и безплатно изтегляне на видео с висока резолюция. Поддържа изтегляне на видеа от YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (бивш Twitter) и други. Насладете се на безпроблемни, висококачествени изтегляния без водни знаци.
// @description:cs Vše v jednom, rychlý a bezplatný downloader videí ve vysoké kvalitě. Podporuje stahování videí z YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (dříve Twitter) og dalších. Užijte si plynulé, kvalitní stahování bez vodoznaků.
// @description:da En alt-i-en, hurtig og gratis HD video downloader. Understøtter video downloads fra YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (tidligere Twitter) og mere. Nyd problemfri, høj kvalitet downloads uden vandmærker.
// @description:de Ein All-in-One, schneller und kostenloser HD-Video-Downloader. Unterstützt das Herunterladen von Videos von YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (ehemals Twitter) und mehr. Genießen Sie nahtlose, hochqualitative Downloads ohne Wasserzeichen.
// @description:el Ένας ολοκληρωμένος, γρήγορος και δωρεάν downloader HD βίντεο. Υποστηρίζει λήψη βίντεο από YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (πρώην Twitter) και άλλα. Απολαύστε απρόσκοπτες, υψηλής ποιότητας λήψεις χωρίς υδατογραφήματα.
// @description:en An all-in-one, fast, and free HD video downloader. Supports video downloads from YouTube, TikTok, Douyin, Instagram, Threads, XiaoHongShu, TED, Facebook, X (formerly Twitter), and more. Enjoy seamless, high-quality downloads with no watermarks.
// @description:eo Ĉio-en-unu, rapida kaj senpaga HD-video-elŝutilo. Subtenas video-elŝutojn de YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (antaŭe Twitter), kaj aliaj. Ŝatu seninterrompajn, altkvalitajn elŝutojn sen akvamaĵoj.
// @description:es Un descargador de video todo en uno, rápido y gratuito en HD. Admite descargas de videos de YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (anteriormente Twitter) y más. Disfruta de descargas de alta calidad sin marcas de agua.
// @description:fi Kaikki yhdessä, nopea ja ilmainen HD-videoiden lataaja. Tukee videoiden lataamista YouTubesta, TikTokista, Douyinista, Instagramista, Facebookista, Threadsista, XiaoHongShusta, X:stä (entinen Twitter) ja muista. Naji saumatonta, korkealaatuista lataamista ilman vesileimoja.
// @description:fr Un téléchargeur vidéo HD tout-en-un, rapide et gratuit. Prend en charge les téléchargements de vidéos depuis YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (anciennement Twitter) et plus encore. Profitez de téléchargements fluides et de haute qualidade sans filigranes.
// @description:fr-CA Un téléchargeur vidéo HD tout-en-un, rapide et gratuit. Prend en charge les téléchargements de vidéos depuis YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (anciennement Twitter) et plus encore. Profitez de téléchargements fluides et de haute qualidade sans filigranes.
// @description:he מנוע הורדות וידאו HD מהיר, חינמי ומקיף. תומך בהורדת וידאו מיוטיوب, טיקטوك, אינסטגרם, Facebook, Threads, XiaoHongShu, TED, X (בעבר טוויטר) ועוד. תהנה מהורדות באיכות גבוהה ללא סימני מים.
// @description:hr Svi-u-jednom, brzi i besplatni HD downloader za video. Podržava preuzimanje videa s YouTubea, TikToka, Instagrama, Facebooka, Threads, XiaoHongShu, TED, X (bivši Twitter) i još mnogo toga. Uživajte u besprijekornim, visokokvalitetnim preuzimanjima bez vodmarks.
// @description:hu Egy minden-egyben, gyors és ingyenes HD videó letöltő. Támogatja a videók letöltését YouTube-ról, TikTok-ról, Instagram-ról, Facebookról, Threads, XiaoHongShu, TED, X (régen Twitter) és másról. Letöltheti a zökkenőmentes, magas minőségű letöltéseket vízjel nélkül.
// @description:id Downloader video HD serba ada, cepat, dan gratis. Mendukung unduhan video dari YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (sebelumnya Twitter), dan banyak lagi. Nikmati unduhan berkualitas tinggi tanpa tanda air.
// @description:it Un downloader video HD completo, veloce e gratuito. Supporta il download di video da YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (precedentemente Twitter) e altri. Goditi download fluidi e di alta qualità senza filigrane.
// @description:ja YouTube の視聴体験を向上させるための高速で無料な HD 動画ダウンローダー。YouTube、TikTok、Douyin、Instagram、Facebook、Threads、XiaoHongShu、X(旧 Twitter)などからの動画ダウンロードをサポート。シームレスで高品質なダウンロードをウォーターマークなしでお楽しみください。
// @description:ka ყველაფერი ერთში, სწრაფი და უფასო HD ვიდეო ჩამოტვირთვა. მხარს უჭერს ვიდეოს ჩამოტვირთვას YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (ყოფილი Twitter) და სხვა პლატფორმებიდან. დატკბით უარყოფითი ნიშნების გარეშე, უმაღლესი ხარისხის ჩამოტვირთვებით.
// @description:ko YouTube 시청 경험을 향상시키는 모든 기능을 갖춘, 빠르고 무료인 HD 비디오 다운로드기. YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X(이전 Twitter) 등에서 비디오 다운로드를 지원합니다. 워터마크 없이 원활한 고품질 다운로드를 즐기세요.
// @description:nb En alt-i-en, rask og gratis HD video nedlaster. Støtter video nedlastinger fra YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (tidligere Twitter), og mer. Nyt sømløse, høy kvalitet nedlastinger uten vannmerker.
// @description:nl Een alles-in-één, snelle en gratis HD-video downloader. Ondersteunt video-downloads van YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (voorheen Twitter) en meer. Geniet van naadloze, hoogwaardige downloads zonder watermerken.
// @description:pl Jedno w jednym, szybki i darmowy pobieracz wideo HD. Obsługuje pobieranie wideo z YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (dawniej Twitter) i innych. Ciesz się płynne, wysokiej jakości pobieranie bez znaków wodnych.
// @description:pt-BR Um downloader de vídeo HD completo, rápido e gratuito. Suporta downloads de vídeos do YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (anteriormente Twitter) e muito mais. Aproveite downloads de alta qualidade sem marcas d'água.
// @description:ro Un downloader video HD rapid și gratuit, totul într-un singur pachet. Suportă descărcarea de videoclipuri de pe YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (fost Twitter) și altele. Bucurați-vă de descărcări de înaltă calitate fără filigrane.
// @description:ru Все-в-одном, быстрый и бесплатный HD видеозагрузчик. Поддерживает загрузку видео с YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (бывший Twitter) и других. Наслаждайтесь загрузками высокого качества без водяных знаков.
// @description:sv En allt-i-ett, snabb og gratis HD-video nedladdare. Stöder video nedladdningar från YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (tidigare Twitter) och mer. Njut av sömlösa, högkvalitativa nedladdningar utan vattenmärken.
// @description:th เครื่องมือดาวน์โหลดวิดีโอ HD ที่รวดเร็วและฟรีแบบครบวงจร รองรับการดาวน์โหลดวิดีโอจาก YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (เดิม Twitter) และอื่นๆ เพลิดเพลินกับการดาวน์โหลดที่มีคุณภาพสูงโดยไม่มีลายน้ำ
// @description:tr Birçok platformdan video indirmenizi destekleyen, hızlı ve ücretsiz HD video indirme aracıdır. YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (eski Twitter) ve daha fazlasını destekler. Su işareti olmadan kesintisiz yüksek kaliteli indirmelerin keyfini çıkarın.
// @description:uk Всі в одному, швидкий та безкоштовний HD відео-завантажувач. Підтримує завантаження відео з YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (колишній Twitter) та інших. Насолоджуйтесь безперешкодними завантаженнями без водяних знаків.
// @description:ug YouTube كۆرۈش تەجرىبەڭىزنى ياخشىلايدىغان، تيز ۋە ھەقسىز HD ۋىدىئو چۈشۈرگۈچى. يۇتۇب، تيك توك، دۇيىن، ئىنستاگرام، Facebook، Threads، XiaoHongShu، TED، X (ئالدىنقى Twitter) ۋە باشقا پلاتفورمالاردىن ۋىدىئو چۈشۈرۈشنى قوللايدۇ. سۇ ئىشاراتسىز ئېغىزدىن سۈپەتلىك چۈشۈرۈشتىن لەززەتلىنىڭ.
// @description:vi Trình tải video HD tất cả trong một, nhanh chóng và miễn phí. Hỗ trợ tải video từ YouTube, TikTok, Douyin, Instagram, Facebook, Threads, XiaoHongShu, TED, X (trước đây là Twitter) và nhiều nền tảng khác. Thưởng thức tải xuống chất lượng cao mượt mà mà không có watermark.
// @description:zh-CN 一款多合一、快速且免费的在线视频下载器。支持从 YouTube、TikTok、抖音、Instagram、Facebook、Threads、TED、小红书、X(Twitter)等平台下载视频。享受无水印、高质量、流畅便捷的下载体验
// @description:zh-TW 一款多合一、快速且免費的線上視頻下載器。支援從 YouTube、TikTok、抖音、Instagram、Facebook、Threads、TED、小紅書、X(Twitter)等平台下載視頻。享受無水印、高質量、流暢便捷的下載體驗
// @namespace ksmo
// @version 3.2.5
// @author ksmo
// @run-at document-start
// @allFrames true
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @grant unsafeWindow
// @match *://*.youtube.com/*
// @match *://x.com/*
// @match *://twitter.com/*
// @match *://*.threads.net/*
// @match *://*.instagram.com/*
// @match *://*.tiktok.com/*
// @match *://*.douyin.com/*
// @match *://*.xiaohongshu.com/*
// @match *://*.facebook.com/*
// @match *://*.snapchat.com/*
// @match *://*.pinterest.com/*
// @match *://*.ted.com/*
// @match *://open.spotify.com/*
// @connect youtube.com
// @connect googlevideo.com
// @connect tiktok.com
// @connect tiktokcdn.com
// @connect instagram.com
// @connect cdninstagram.com
// @connect twitter.com
// @connect twimg.com
// @connect facebook.com
// @connect fbcdn.net
// @connect threads.net
// @connect douyin.com
// @connect xiaohongshu.com
// @connect ted.com
// @connect snapchat.com
// @connect sc-cdn.net
// @connect pinterest.com
// @connect pinimg.com
// @connect spotify.com
// @connect spotifydown.com
// @connect corsproxy.io
// @connect api.allorigins.win
// @connect app.ytdown.to
// @connect snapscooper.com
// @connect y2meta.is
// @connect savefrom.net
// @connect snaptik.app
// @connect ttdownloader.tools
// @connect savetik.co
// @connect seekin.ai
// @connect fdownloader.net
// @connect boilerplatehub.com
// @connect twittervideodownloader.com
// @connect ssstwitter.com
// @connect sssinstagram.com
// @connect saveclip.app
// @connect fastdl.app
// @connect downreels.com
// @connect storygrab.io
// @connect schatdownloader.com
// @connect pindown.io
// @connect pinpea.com
// @connect tool77.com
// @antifeature ads This script uses external services as fallback when direct download is blocked.
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.__omniDownInitialized) return;
window.__omniDownInitialized = true;
// Early-exit: skip non-embed iframes to avoid CPU waste in ad frames
if (window.self !== window.top && !window.location.pathname.startsWith('/embed/')) {
return;
}
const SETTINGS_KEYS = {
theme: 'omnidown_theme',
accent: 'omnidown_accent',
glassIntensity: 'omnidown_glass',
borderRadius: 'omnidown_radius',
fontSize: 'omnidown_fontsize',
uiMode: 'omnidown_ui_mode',
language: 'omnidown_lang',
autoFallback: 'omnidown_autofallback',
fallbackService: 'ksmo_dl_fallback_service',
enableHotkey: 'omnidown_hotkey',
showButton: 'omnidown_showbutton',
buttonOpacity: 'omnidown_btnopacity',
buttonSize: 'omnidown_btnsize',
cobaltApiUrl: 'omnidown_cobalturl',
customProxy: 'omnidown_proxy',
concurrentDownloads: 'omnidown_concurrent',
youtubeNativeButton: 'omnidown_ytbtn',
youtubeSpaSupport: 'omnidown_ytspa',
youtubePlaylist: 'omnidown_ytpl',
youtubeSubtitles: 'omnidown_ytsub',
youtubeNParam: 'omnidown_ytnparam',
youtubeQuality: 'omnidown_ytquality',
tiktokSidebar: 'omnidown_ttsidebar',
tiktokNetwork: 'omnidown_ttnetwork',
tiktokNoWatermark: 'omnidown_ttwatermark',
tiktokAutoDetect: 'omnidown_ttautodetect',
tiktokFallback: 'omnidown_ttfallback',
instagramCarousel: 'omnidown_igcarousel',
instagramHls: 'omnidown_ighls',
instagramStory: 'omnidown_igstory',
xDirectMp4: 'omnidown_xmp4',
xMultivideo: 'omnidown_xmulti',
facebookEmbed: 'omnidown_fbembed',
debugMode: 'omnidown_debug'
};
const SETTINGS_DEFAULTS = {
theme: 'dark',
accent: '#6c5ce7',
glassIntensity: 85,
borderRadius: 16,
fontSize: 14,
uiMode: 'popup',
language: 'en',
autoFallback: false,
fallbackService: 'cobalt',
enableHotkey: true,
showButton: true,
buttonOpacity: 70,
buttonSize: 56,
cobaltApiUrl: 'cobalt.tools',
customProxy: '',
concurrentDownloads: 3,
youtubeNativeButton: true,
youtubeSpaSupport: true,
youtubePlaylist: true,
youtubeSubtitles: true,
youtubeNParam: true,
youtubeQuality: 'auto',
tiktokSidebar: true,
tiktokNetwork: true,
tiktokNoWatermark: true,
tiktokAutoDetect: true,
tiktokFallback: 'snaptik',
instagramCarousel: true,
instagramHls: true,
instagramStory: true,
xDirectMp4: true,
xMultivideo: true,
facebookEmbed: true,
debugMode: false
};
const getSetting = (key, defaultValue) => {
let val = null;
if (typeof GM_getValue !== 'undefined') {
val = GM_getValue(key, null);
} else {
val = localStorage.getItem(key);
}
if (val === null) {
const settingName = Object.keys(SETTINGS_KEYS).find(k => SETTINGS_KEYS[k] === key);
if (settingName && SETTINGS_DEFAULTS[settingName] !== undefined) {
return SETTINGS_DEFAULTS[settingName];
}
return defaultValue;
}
if (val === 'true') return true;
if (val === 'false') return false;
if (/^\d+$/.test(val)) return parseInt(val, 10);
return val;
};
const setSetting = (key, value) => {
if (typeof GM_setValue !== 'undefined') {
GM_setValue(key, value);
} else {
localStorage.setItem(key, value.toString());
}
};
const getUIMode = () => getSetting(SETTINGS_KEYS.uiMode, 'popup');
const setUIMode = (mode) => {
setSetting(SETTINGS_KEYS.uiMode, mode);
showToast(mode === 'sidebar' ? getText('uiModeSidebar') : getText('uiModePopup'));
};
const toggleUIMode = () => setUIMode(getUIMode() === 'sidebar' ? 'popup' : 'sidebar');
const hexToRgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
};
const applyTheme = (theme) => {
if (theme === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-od-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-od-theme', theme);
}
};
const applyAccent = (color) => {
document.documentElement.style.setProperty('--od-accent', color);
const rgb = hexToRgb(color);
if (rgb) {
document.documentElement.style.setProperty('--od-accent-rgb', `${rgb.r}, ${rgb.g}, ${rgb.b}`);
}
};
const applyVisuals = () => {
const theme = getSetting(SETTINGS_KEYS.theme, 'dark');
const accent = getSetting(SETTINGS_KEYS.accent, '#6c5ce7');
const glass = getSetting(SETTINGS_KEYS.glassIntensity, 85);
const radius = getSetting(SETTINGS_KEYS.borderRadius, 16);
const font = getSetting(SETTINGS_KEYS.fontSize, 14);
const opacity = getSetting(SETTINGS_KEYS.buttonOpacity, 70);
const size = getSetting(SETTINGS_KEYS.buttonSize, 56);
applyTheme(theme);
applyAccent(accent);
document.documentElement.style.setProperty('--od-glass-blur', `${(glass / 100) * 20}px`);
document.documentElement.style.setProperty('--od-glass-opacity', `${glass / 100}`);
document.documentElement.style.setProperty('--od-radius', `${radius}px`);
document.documentElement.style.setProperty('--od-font-size', `${font}px`);
const fab = document.getElementById('ksmo-floating-dl');
if (fab) {
fab.style.setProperty('--fab-size', `${size}px`);
fab.style.setProperty('--fab-opacity', `${opacity / 100}`);
}
};
// Helper functions to comply with Trusted Types policy without using innerHTML
const createSvgIcon = (type, options = {}) => {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
if (type === 'download') {
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke-width', '2.5');
const width = options.width || '20';
const height = options.height || '20';
svg.setAttribute('width', width);
svg.setAttribute('height', height);
if (options.style) {
Object.assign(svg.style, options.style);
}
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4');
const polyline = document.createElementNS(svgNS, 'polyline');
polyline.setAttribute('points', '7 10 12 15 17 10');
const line = document.createElementNS(svgNS, 'line');
line.setAttribute('x1', '12');
line.setAttribute('y1', '15');
line.setAttribute('x2', '12');
line.setAttribute('y2', '3');
svg.appendChild(path);
svg.appendChild(polyline);
svg.appendChild(line);
} else if (type === 'minimize') {
svg.setAttribute('width', options.width || '18');
svg.setAttribute('height', options.height || '18');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke-width', '2');
const line = document.createElementNS(svgNS, 'line');
line.setAttribute('x1', '5');
line.setAttribute('y1', '12');
line.setAttribute('x2', '19');
line.setAttribute('y2', '12');
svg.appendChild(line);
} else if (type === 'expand') {
svg.setAttribute('width', options.width || '18');
svg.setAttribute('height', options.height || '18');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke-width', '2');
const line1 = document.createElementNS(svgNS, 'line');
line1.setAttribute('x1', '4');
line1.setAttribute('y1', '12');
line1.setAttribute('x2', '20');
line1.setAttribute('y2', '12');
const line2 = document.createElementNS(svgNS, 'line');
line2.setAttribute('x1', '12');
line2.setAttribute('y1', '4');
line2.setAttribute('x2', '12');
line2.setAttribute('y2', '20');
svg.appendChild(line1);
svg.appendChild(line2);
} else if (type === 'close') {
svg.setAttribute('width', options.width || '20');
svg.setAttribute('height', options.height || '20');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke-width', '2');
const line1 = document.createElementNS(svgNS, 'line');
line1.setAttribute('x1', '18');
line1.setAttribute('y1', '6');
line1.setAttribute('x2', '6');
line1.setAttribute('y2', '18');
const line2 = document.createElementNS(svgNS, 'line');
line2.setAttribute('x1', '6');
line2.setAttribute('y1', '6');
line2.setAttribute('x2', '18');
line2.setAttribute('y2', '18');
svg.appendChild(line1);
svg.appendChild(line2);
} else if (type === 'settings') {
svg.setAttribute('width', options.width || '18');
svg.setAttribute('height', options.height || '18');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke-width', '2');
const circle = document.createElementNS(svgNS, 'circle');
circle.setAttribute('cx', '12');
circle.setAttribute('cy', '12');
circle.setAttribute('r', '3');
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z');
svg.appendChild(circle);
svg.appendChild(path);
}
return svg;
};
const setSafeHTMLWithBr = (element, text) => {
element.textContent = '';
const parts = text.split(/<br\s*\/?>/i);
parts.forEach((part, index) => {
if (index > 0) {
element.appendChild(document.createElement('br'));
}
element.appendChild(document.createTextNode(part));
});
};
// TikTok ForYou Copier State and Helpers
const tiktokState = {
currentVideoId: null,
currentUsername: null,
videoData: new Map()
};
function getVisibleArticle() {
const activeVideo = getActiveVideoElement();
if (activeVideo) {
let parent = activeVideo.parentElement;
while (parent && parent !== document.body) {
if (parent.tagName === 'ARTICLE' ||
parent.matches?.('[data-e2e="recommend-list-item"]') ||
parent.matches?.('[class*="DivItemContainer"]')) {
return parent;
}
parent = parent.parentElement;
}
}
const articles = document.querySelectorAll('article, [data-e2e="recommend-list-item"], [class*="DivItemContainer"]');
let best = null;
let bestScore = -1;
for (const article of articles) {
const rect = article.getBoundingClientRect();
const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
const score = rect.height > 0 ? visibleHeight / rect.height : 0;
if (score > bestScore && score > 0.3) {
bestScore = score;
best = article;
}
}
return best;
}
function getUsernameFromArticle(article) {
if (!article) return null;
const selectors = [
'a[href^="/@"]',
'[data-e2e="browse-username"]',
'[data-e2e="user-avatar"] + a',
'div[class*="DivAuthor"] a[href^="/@"]',
'div[class*="author"] a[href^="/@"]'
];
for (const sel of selectors) {
const el = article.querySelector(sel);
if (el) {
const href = el.getAttribute('href');
if (href && href.startsWith('/@')) {
const match = href.match(/^\/(@[^\/]+)/);
if (match) return match[1];
}
const text = el.textContent.trim();
if (text.startsWith('@') && text.length > 1 && text.length < 50) {
return text;
}
}
}
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, null, false);
let node;
while (node = walker.nextNode()) {
const text = node.textContent.trim();
if (text.startsWith('@') && text.length > 1 && text.length < 50 && !text.includes('/')) {
return text;
}
}
return null;
}
function getVideoIdFromArticle(article) {
if (!article) return null;
// Method 1: data attributes
const el = article.querySelector('[data-video-id], [data-item-id], video[data-id]');
if (el) {
const id = el.getAttribute('data-video-id') || el.getAttribute('data-item-id') || el.getAttribute('data-id');
if (id && /^\d{19}$/.test(id)) return id;
}
// Method 2: Find links with video IDs in href
const links = article.querySelectorAll('a[href*="/video/"]');
for (const link of links) {
const match = link.href.match(/\/video\/(\d+)/);
if (match && /^\d{19}$/.test(match[1])) return match[1];
}
// Method 3: Find any element with data-e2e attributes
const e2eElements = article.querySelectorAll('[data-e2e*="video"], [data-e2e*="item"]');
for (const el of e2eElements) {
const id = el.getAttribute('data-item-id') || el.getAttribute('data-video-id');
if (id && /^\d{19}$/.test(id)) return id;
}
// Method 4: Check for video element src or poster
const video = article.querySelector('video');
if (video) {
const src = video.src || video.getAttribute('src') || '';
const poster = video.getAttribute('poster') || '';
const match = src.match(/(\d{19})/) || poster.match(/(\d{19})/);
if (match) return match[1];
}
// Method 5: Search all text content for 19-digit IDs
const text = article.textContent || '';
const idMatches = text.match(/\b(\d{19})\b/g);
if (idMatches) {
// Return the first one that looks like a video ID
for (const id of idMatches) {
if (id.startsWith('7') || id.startsWith('6') || id.startsWith('5')) {
return id;
}
}
return idMatches[0];
}
// Method 6: Check scripts within the article
const scripts = article.querySelectorAll('script');
for (const script of scripts) {
const text = script.textContent;
const match = text.match(/"id":"(\d{19})"/);
if (match) return match[1];
}
return null;
}
function buildTikTokLink() {
if (!tiktokState.currentVideoId) return null;
let user = tiktokState.currentUsername;
if (!user && tiktokState.videoData.has(tiktokState.currentVideoId)) {
user = tiktokState.videoData.get(tiktokState.currentVideoId).username;
}
if (!user) {
const article = getVisibleArticle();
user = getUsernameFromArticle(article);
}
if (user && user !== 'user') {
user = user.replace(/^@/, '');
user = '@' + user;
} else {
user = 'user';
}
return `https://www.tiktok.com/${user}/video/${tiktokState.currentVideoId}`;
}
function getVideoIdFromURL() {
// Note: short URLs (tiktok.com/t/XXXX) automatically redirect to standard /video/ID URLs in the browser.
// TODO: Add resolver for short URLs if direct request is needed without redirection.
const match = window.location.href.match(/\/video\/(\d+)/);
return match ? match[1] : null;
}
function formatNumber(num) {
if (num === undefined || num === null) return '0';
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return num.toString();
}
function parseTikTokItemList(jsonText) {
try {
const data = JSON.parse(jsonText);
const items = data.itemList || (data.itemInfo && [data.itemInfo.itemStruct]) || [];
if (Array.isArray(items)) {
for (const item of items) {
if (item && item.id) {
const vid = item.id;
const username = item.author ? ('@' + item.author.uniqueId.replace(/^@/, '')) : null;
const stats = item.stats || null;
const desc = item.desc || '';
const playUrl = item.video ? (item.video.playAddr || item.video.downloadAddr) : null;
tiktokState.videoData.set(vid, {
username,
stats: stats ? {
playCount: stats.playCount || 0,
diggCount: stats.diggCount || 0,
commentCount: stats.commentCount || 0,
shareCount: stats.shareCount || 0
} : null,
desc: desc,
url: playUrl,
timestamp: Date.now()
});
}
}
}
} catch (e) {
try {
const videoMatches = [...jsonText.matchAll(/"id":"(\d{19})"/g)];
const userMatches = [...jsonText.matchAll(/"uniqueId":"([^"]+)"/g)];
for (let i = 0; i < videoMatches.length; i++) {
const vid = videoMatches[i][1];
const user = userMatches[i] ? userMatches[i][1] : null;
if (user) {
tiktokState.videoData.set(vid, {
username: '@' + user.replace(/^@/, ''),
timestamp: Date.now()
});
}
}
} catch (err) { }
}
}
// ===== TikTok Sidebar v6 =====
function createTikTokSidebar() {
if (document.getElementById('ksmo-tt-sidebar')) return;
const sidebar = document.createElement('div');
sidebar.id = 'ksmo-tt-sidebar';
const style = document.createElement('style');
style.textContent = `
#ksmo-tt-sidebar {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 14px 8px;
background: rgba(22, 24, 35, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.08);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
min-width: 72px;
transition: opacity 0.3s ease, transform 0.3s ease;
}
#ksmo-tt-sidebar.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-50%) translateX(20px);
}
.ksmo-tt-status {
font-size: 10px;
font-weight: 700;
color: #fe2c55;
margin-bottom: 2px;
text-align: center;
max-width: 70px;
line-height: 1.2;
}
.ksmo-tt-status.ready { color: #25f4ee; }
.ksmo-tt-status.partial { color: #f7c948; }
.ksmo-tt-debug {
font-size: 9px;
color: rgba(255,255,255,0.45);
text-align: center;
max-width: 70px;
word-break: break-all;
line-height: 1.3;
margin-bottom: 4px;
}
.ksmo-tt-divider {
width: 28px;
height: 1px;
background: rgba(255,255,255,0.12);
margin: 2px 0;
}
.ksmo-tt-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
background: none;
border: none;
cursor: pointer;
color: #fff;
padding: 6px 4px;
transition: transform 0.12s ease, opacity 0.2s;
width: 100%;
}
.ksmo-tt-btn:hover { transform: scale(1.08); opacity: 1; }
.ksmo-tt-btn:active { transform: scale(0.95); }
.ksmo-tt-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.ksmo-tt-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.08);
border-radius: 50%;
font-size: 18px;
transition: background 0.2s;
}
.ksmo-tt-btn:hover .ksmo-tt-icon {
background: rgba(255,255,255,0.15);
}
.ksmo-tt-btn.cobalt .ksmo-tt-icon {
background: rgba(37, 244, 238, 0.15);
}
.ksmo-tt-btn.cobalt:hover .ksmo-tt-icon {
background: rgba(37, 244, 238, 0.25);
}
.ksmo-tt-label {
font-size: 10px;
font-weight: 600;
color: #fff;
}
.ksmo-tt-btn.cobalt .ksmo-tt-label {
color: #25f4ee;
}
.ksmo-tt-preview {
font-size: 8px;
color: rgba(255,255,255,0.35);
max-width: 70px;
word-break: break-all;
text-align: center;
line-height: 1.3;
margin-top: 2px;
min-height: 20px;
}
@keyframes ksmo-tt-toast {
0% { opacity: 0; transform: translateX(-50%) translateY(10px); }
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
80% { opacity: 1; }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
.ksmo-tt-toast {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(22,24,35,0.95);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 999999;
animation: ksmo-tt-toast 2s ease forwards;
font-family: inherit;
border: 1px solid rgba(255,255,255,0.1);
}
`;
document.head.appendChild(style);
// Status
const statusEl = document.createElement('div');
statusEl.id = 'ksmo-tt-status';
statusEl.className = 'ksmo-tt-status';
statusEl.textContent = '🔴 ' + getText('waiting');
sidebar.appendChild(statusEl);
// Debug info
const debugEl = document.createElement('div');
debugEl.id = 'ksmo-tt-debug';
debugEl.className = 'ksmo-tt-debug';
debugEl.textContent = 'ID: -\n@: -';
sidebar.appendChild(debugEl);
// Divider
const divider1 = document.createElement('div');
divider1.className = 'ksmo-tt-divider';
sidebar.appendChild(divider1);
// ===== COPY BUTTON =====
const copyBtn = document.createElement('button');
copyBtn.className = 'ksmo-tt-btn';
copyBtn.id = 'ksmo-tt-copy';
copyBtn.title = getText('copyLink');
const copyIcon = document.createElement('div');
copyIcon.className = 'ksmo-tt-icon';
copyIcon.textContent = '🔗';
copyBtn.appendChild(copyIcon);
const copyLabel = document.createElement('span');
copyLabel.className = 'ksmo-tt-label';
copyLabel.textContent = getText('copy');
copyBtn.appendChild(copyLabel);
copyBtn.addEventListener('click', () => {
const link = buildTikTokLink();
if (link) {
copyToClipboard(link)
.then(() => showTikTokToast(getText('linkCopied')))
.catch(() => showTikTokToast(getText('copyError')));
} else {
showTikTokToast(getText('incompleteData'));
}
});
sidebar.appendChild(copyBtn);
// ===== DOWNLOAD BUTTON =====
const downloadBtn = document.createElement('button');
downloadBtn.className = 'ksmo-tt-btn';
downloadBtn.id = 'ksmo-tt-download';
downloadBtn.title = getText('download');
const downloadIcon = document.createElement('div');
downloadIcon.className = 'ksmo-tt-icon';
downloadIcon.textContent = '⬇️';
downloadBtn.appendChild(downloadIcon);
const downloadLabel = document.createElement('span');
downloadLabel.className = 'ksmo-tt-label';
downloadLabel.textContent = getText('download');
downloadBtn.appendChild(downloadLabel);
downloadBtn.addEventListener('click', () => {
const link = buildTikTokLink();
if (link) {
openDownloadModal();
showTikTokToast(getText('openingMenu'));
} else {
showTikTokToast(getText('incompleteData'));
}
});
sidebar.appendChild(downloadBtn);
// ===== COBALT BUTTON =====
const cobaltBtn = document.createElement('button');
cobaltBtn.className = 'ksmo-tt-btn cobalt';
cobaltBtn.id = 'ksmo-tt-cobalt';
cobaltBtn.title = getText('cobaltFast');
const cobaltIcon = document.createElement('div');
cobaltIcon.className = 'ksmo-tt-icon';
cobaltIcon.textContent = '⚡';
cobaltBtn.appendChild(cobaltIcon);
const cobaltLabel = document.createElement('span');
cobaltLabel.className = 'ksmo-tt-label';
cobaltLabel.textContent = 'Cobalt';
cobaltBtn.appendChild(cobaltLabel);
cobaltBtn.addEventListener('click', () => {
const link = buildTikTokLink();
if (link) {
triggerCobaltDownload(link);
showTikTokToast(getText('cobaltStarted'));
} else {
showTikTokToast(getText('incompleteData'));
}
});
sidebar.appendChild(cobaltBtn);
// Divider
const divider2 = document.createElement('div');
divider2.className = 'ksmo-tt-divider';
sidebar.appendChild(divider2);
// ===== REFRESH BUTTON =====
const refreshBtn = document.createElement('button');
refreshBtn.className = 'ksmo-tt-btn';
refreshBtn.id = 'ksmo-tt-refresh';
refreshBtn.title = getText('refresh');
const refreshIcon = document.createElement('div');
refreshIcon.className = 'ksmo-tt-icon';
refreshIcon.textContent = '🔄';
refreshBtn.appendChild(refreshIcon);
const refreshLabel = document.createElement('span');
refreshLabel.className = 'ksmo-tt-label';
refreshLabel.textContent = getText('refresh');
refreshBtn.appendChild(refreshLabel);
refreshBtn.addEventListener('click', () => {
tiktokState.currentVideoId = null;
tiktokState.currentUsername = null;
tiktokState.videoData.clear();
updateTikTokUI();
showTikTokToast(getText('refreshing'));
setupTikTokObserver();
});
sidebar.appendChild(refreshBtn);
// Preview do link
const previewEl = document.createElement('div');
previewEl.id = 'ksmo-tt-preview';
previewEl.className = 'ksmo-tt-preview';
sidebar.appendChild(previewEl);
document.body.appendChild(sidebar);
tiktokState.sidebarCreated = true;
}
function updateTikTokUI() {
const statusEl = document.getElementById('ksmo-tt-status');
const debugEl = document.getElementById('ksmo-tt-debug');
const previewEl = document.getElementById('ksmo-tt-preview');
const copyBtn = document.getElementById('ksmo-tt-copy');
const downloadBtn = document.getElementById('ksmo-tt-download');
const cobaltBtn = document.getElementById('ksmo-tt-cobalt');
if (!document.getElementById('ksmo-tt-sidebar')) {
createTikTokSidebar();
}
if (!statusEl || !debugEl) return;
const hasId = !!tiktokState.currentVideoId;
const hasUser = !!tiktokState.currentUsername;
const ready = hasId && hasUser;
if (ready) {
statusEl.textContent = '🟢 ' + getText('ready');
statusEl.className = 'ksmo-tt-status ready';
} else if (hasId) {
statusEl.textContent = '🟡 ' + getText('idOnly');
statusEl.className = 'ksmo-tt-status partial';
} else {
statusEl.textContent = '🔴 ' + getText('waiting');
statusEl.className = 'ksmo-tt-status';
}
// Update debug info without innerHTML (Trusted Types safe)
const idShort = tiktokState.currentVideoId ? tiktokState.currentVideoId.slice(-6) : '-';
const userShort = tiktokState.currentUsername || '-';
debugEl.textContent = '';
debugEl.appendChild(document.createTextNode('ID: ' + idShort));
debugEl.appendChild(document.createElement('br'));
debugEl.appendChild(document.createTextNode(userShort));
if (previewEl) {
const link = buildTikTokLink();
previewEl.textContent = link ? link.replace('https://www.tiktok.com/', '') : '';
}
[copyBtn, downloadBtn, cobaltBtn].forEach(btn => {
if (btn) btn.disabled = !hasId;
});
}
// ===== TikTok Sidebar v6 Helper Functions =====
function showTikTokToast(message) {
const existing = document.querySelector('.ksmo-tt-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'ksmo-tt-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
if (toast.parentNode) toast.remove();
}, 2000);
}
function copyToClipboard(text) {
return new Promise((resolve, reject) => {
if (typeof GM_setClipboard !== 'undefined') {
try {
GM_setClipboard(text);
resolve(true);
return;
} catch (e) { }
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => resolve(true))
.catch(() => fallbackCopy());
return;
}
fallbackCopy();
function fallbackCopy() {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;opacity:0;pointer-events:none;z-index:-1;';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, 99999);
const success = document.execCommand('copy');
document.body.removeChild(textarea);
success ? resolve(true) : reject(new Error('execCommand failed'));
} catch (e) {
reject(e);
}
}
});
}
function triggerCobaltDownload(url) {
const apiUrl = getSetting(SETTINGS_KEYS.cobaltApiUrl) || 'https://api.cobalt.tools/api/json';
const body = {
url: url,
vQuality: '720',
aFormat: 'mp3',
filenamePattern: 'classic'
};
fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
if (data.url) {
const a = document.createElement('a');
a.href = data.url;
a.download = '';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showTikTokToast(getText('downloadStarted'));
} else if (data.error) {
showTikTokToast(getText('cobaltError') + ': ' + data.error);
}
})
.catch(err => {
console.error('[OmniDown] Cobalt error:', err);
showTikTokToast(getText('cobaltError'));
window.open('https://cobalt.tools/#' + encodeURIComponent(url), '_blank');
});
}
function isTikTokFYP() {
return window.location.hostname.includes('tiktok.com') &&
(window.location.pathname === '/foryou' || window.location.pathname === '/');
}
// Widget creation utilities for OmniDown Config Dashboard
const odCreateSwitch = (key, onChange) => {
const sw = document.createElement('div');
sw.className = 'od-switch';
let val = getSetting(SETTINGS_KEYS[key], SETTINGS_DEFAULTS[key]);
if (val) sw.classList.add('active');
sw.addEventListener('click', () => {
const active = sw.classList.toggle('active');
setSetting(SETTINGS_KEYS[key], active);
if (onChange) onChange(active);
});
return sw;
};
const odCreateSlider = (key, min, max, unit, onChange) => {
const container = document.createElement('div');
container.className = 'od-slider-container';
const header = document.createElement('div');
header.className = 'od-slider-header';
const titleSpan = document.createElement('span');
titleSpan.textContent = getText(key) || key;
const valSpan = document.createElement('span');
let initialVal = getSetting(SETTINGS_KEYS[key], SETTINGS_DEFAULTS[key]);
valSpan.textContent = initialVal + unit;
header.appendChild(titleSpan);
header.appendChild(valSpan);
container.appendChild(header);
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'od-slider';
slider.min = min;
slider.max = max;
slider.value = initialVal;
slider.addEventListener('input', () => {
valSpan.textContent = slider.value + unit;
setSetting(SETTINGS_KEYS[key], parseInt(slider.value, 10));
if (onChange) onChange(parseInt(slider.value, 10));
});
container.appendChild(slider);
return container;
};
const odCreateSelect = (key, options, onChange) => {
const sel = document.createElement('select');
sel.className = 'od-select';
let current = getSetting(SETTINGS_KEYS[key], SETTINGS_DEFAULTS[key]);
options.forEach(opt => {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
if (opt.value === current) el.selected = true;
sel.appendChild(el);
});
sel.addEventListener('change', () => {
setSetting(SETTINGS_KEYS[key], sel.value);
if (onChange) onChange(sel.value);
});
return sel;
};
const odCreateInput = (key, placeholder, onChange) => {
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'od-input';
inp.placeholder = placeholder;
inp.value = getSetting(SETTINGS_KEYS[key], SETTINGS_DEFAULTS[key]);
inp.addEventListener('input', () => {
setSetting(SETTINGS_KEYS[key], inp.value.trim());
if (onChange) onChange(inp.value.trim());
});
return inp;
};
const odCreateCard = (title, desc, controlEl) => {
const card = document.createElement('div');
card.className = 'od-card';
const header = document.createElement('div');
header.className = 'od-card-header';
const titleDiv = document.createElement('div');
titleDiv.className = 'od-card-title';
titleDiv.textContent = title;
header.appendChild(titleDiv);
if (controlEl && controlEl.className === 'od-switch') {
header.appendChild(controlEl);
}
card.appendChild(header);
if (desc) {
const descDiv = document.createElement('div');
descDiv.className = 'od-card-description';
descDiv.textContent = desc;
card.appendChild(descDiv);
}
if (controlEl && controlEl.className !== 'od-switch') {
card.appendChild(controlEl);
}
return card;
};
const testCobaltConnection = (badge, statusText) => {
badge.style.background = 'rgba(253, 203, 110, 0.15)';
badge.style.color = '#fdcb6e';
statusText.textContent = 'Testing...';
const cobaltUrl = getSetting(SETTINGS_KEYS.cobaltApiUrl, 'cobalt.tools');
const cleanUrl = cobaltUrl.replace(/^(https?:\/\/)?/, 'https://');
GM_xmlhttpRequest({
method: 'GET',
url: cleanUrl,
timeout: 5000,
onload: (resp) => {
if (resp.status >= 200 && resp.status < 400) {
badge.style.background = 'rgba(0, 184, 148, 0.15)';
badge.style.color = '#00b894';
statusText.textContent = '🟢 Online';
} else {
badge.style.background = 'rgba(255, 118, 117, 0.15)';
badge.style.color = '#ff7675';
statusText.textContent = '🔴 Offline (' + resp.status + ')';
}
},
onerror: () => {
badge.style.background = 'rgba(255, 118, 117, 0.15)';
badge.style.color = '#ff7675';
statusText.textContent = '🔴 Offline';
},
ontimeout: () => {
badge.style.background = 'rgba(255, 118, 117, 0.15)';
badge.style.color = '#ff7675';
statusText.textContent = '🔴 Timeout';
}
});
};
let activeDashboard = null;
function openDashboard() {
if (activeDashboard) return;
activeDashboard = document.createElement('div');
activeDashboard.id = 'omnidown-dashboard';
const panel = document.createElement('div');
panel.className = 'od-panel';
// 1. Header
const header = document.createElement('div');
header.className = 'od-header';
const title = document.createElement('div');
title.className = 'od-header-title';
title.textContent = '⚙️ ' + getText('dashboardTitle');
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.className = 'od-header-close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', closeDashboard);
header.appendChild(closeBtn);
panel.appendChild(header);
// 2. Body
const body = document.createElement('div');
body.className = 'od-body';
// 2a. Navigation Sidebar
const nav = document.createElement('div');
nav.className = 'od-nav';
const contentArea = document.createElement('div');
contentArea.className = 'od-content';
const tabs = [
{ id: 'appearance', label: '🎨 ' + getText('appearance') },
{ id: 'general', label: '📋 ' + getText('general') },
{ id: 'advanced', label: '🔧 ' + getText('advanced') },
{ id: 'siteSpecific', label: '🎬 ' + getText('siteSpecific') },
{ id: 'about', label: 'ℹ️ ' + getText('about') }
];
const navButtons = [];
const renderTab = (tabId) => {
contentArea.textContent = ''; // clear safely
const tabContent = document.createElement('div');
tabContent.className = 'od-tab-content';
if (tabId === 'appearance') {
const title = document.createElement('div');
title.className = 'od-section-title';
title.textContent = '🎨 ' + getText('appearance');
tabContent.appendChild(title);
const grid = document.createElement('div');
grid.className = 'od-grid';
// 1. Theme Mode Switch
const themeSelect = odCreateSelect('theme', [
{ value: 'dark', label: '🌙 ' + getText('darkMode') },
{ value: 'light', label: '☀️ ' + getText('lightMode') },
{ value: 'auto', label: '🌓 ' + getText('autoTheme') }
], (val) => {
applyTheme(val);
});
grid.appendChild(odCreateCard(getText('darkMode'), 'Theme Selection', themeSelect));
// 2. Accent Color Grid
const colorGrid = document.createElement('div');
colorGrid.className = 'od-color-grid';
const colors = [
{ hex: '#6c5ce7', name: 'Purple' },
{ hex: '#0984e3', name: 'Blue' },
{ hex: '#00b894', name: 'Green' },
{ hex: '#f1c40f', name: 'Yellow' },
{ hex: '#d63031', name: 'Red' },
{ hex: '#dfe6e9', name: 'White' }
];
let currentAccent = getSetting(SETTINGS_KEYS.accent, '#6c5ce7');
colors.forEach(c => {
const circle = document.createElement('div');
circle.className = 'od-color-circle';
circle.style.backgroundColor = c.hex;
circle.title = c.name;
if (c.hex === currentAccent) circle.classList.add('selected');
circle.addEventListener('click', () => {
colorGrid.querySelectorAll('.od-color-circle').forEach(el => el.classList.remove('selected'));
circle.classList.add('selected');
setSetting(SETTINGS_KEYS.accent, c.hex);
applyAccent(c.hex);
});
colorGrid.appendChild(circle);
});
grid.appendChild(odCreateCard(getText('accentColor'), 'Accent Color', colorGrid));
// 3. Glassmorphism Slider
const glassSlider = odCreateSlider('glassIntensity', 0, 100, '%', () => applyVisuals());
grid.appendChild(odCreateCard(getText('glassIntensity'), 'Backdrop blur & transparency', glassSlider));
// 4. Border Radius Slider
const radiusSlider = odCreateSlider('borderRadius', 0, 24, 'px', () => applyVisuals());
grid.appendChild(odCreateCard(getText('borderRadius'), 'Card & button corner curves', radiusSlider));
// 5. Font Size Slider
const fontSlider = odCreateSlider('fontSize', 12, 18, 'px', () => applyVisuals());
grid.appendChild(odCreateCard(getText('fontSize'), 'Global text scaling', fontSlider));
tabContent.appendChild(grid);
// Live Card Preview
const previewTitle = document.createElement('div');
previewTitle.className = 'od-section-title';
previewTitle.textContent = '👀 Live Card Preview';
tabContent.appendChild(previewTitle);
const previewCard = document.createElement('div');
previewCard.className = 'od-preview-card';
const previewHead = document.createElement('h3');
previewHead.textContent = 'OmniDown Visuals';
previewHead.style.margin = '0 0 10px 0';
const previewText = document.createElement('p');
previewText.textContent = 'This card previews your theme, font size, border radius, accent colors, and glassmorphism strength in real-time!';
previewText.style.fontSize = '12px';
previewText.style.color = 'var(--od-text-secondary)';
previewText.style.margin = '0 0 15px 0';
const previewBtn = document.createElement('button');
previewBtn.className = 'od-btn-primary';
previewBtn.style.margin = '0 auto';
previewBtn.textContent = 'Accent Action';
previewCard.appendChild(previewHead);
previewCard.appendChild(previewText);
previewCard.appendChild(previewBtn);
tabContent.appendChild(previewCard);
}
else if (tabId === 'general') {
const title = document.createElement('div');
title.className = 'od-section-title';
title.textContent = '📋 ' + getText('general');
tabContent.appendChild(title);
const grid = document.createElement('div');
grid.className = 'od-grid';
const langSelect = odCreateSelect('language', [
{ value: 'en', label: 'English' },
{ value: 'pt', label: 'Português' },
{ value: 'es', label: 'Español' },
{ value: 'zh-CN', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'vi', label: 'Tiếng Việt' }
], (val) => {
const titleText = activeDashboard.querySelector('.od-header-title');
if (titleText) titleText.textContent = '⚙️ ' + getText('dashboardTitle');
});
grid.appendChild(odCreateCard('Language', 'Interface language setting', langSelect));
const uiModeSelect = odCreateSelect('uiMode', [
{ value: 'popup', label: 'Popup / Modal (Default)' },
{ value: 'sidebar', label: 'Right Sidebar Mode' }
]);
grid.appendChild(odCreateCard('UI Layout Mode', 'Toggle between sidebar or dialog view', uiModeSelect));
const autoFallbackSw = odCreateSwitch('autoFallback');
grid.appendChild(odCreateCard('Auto-Fallback', 'Redirect to fallback without dialog when direct download is blocked', autoFallbackSw));
const hotkeySw = odCreateSwitch('enableHotkey');
grid.appendChild(odCreateCard('Keyboard Shortcut', 'Press Ctrl + Alt + Shift + D to open', hotkeySw));
const btnSw = odCreateSwitch('showButton');
grid.appendChild(odCreateCard('Floating Action Button', 'Show floating download badge on pages', btnSw));
const sizeSlider = odCreateSlider('buttonSize', 40, 80, 'px', () => applyVisuals());
grid.appendChild(odCreateCard('FAB Size', 'Adjust FAB button size', sizeSlider));
const opacitySlider = odCreateSlider('buttonOpacity', 10, 100, '%', () => applyVisuals());
grid.appendChild(odCreateCard('FAB Opacity', 'Adjust FAB idle opacity', opacitySlider));
tabContent.appendChild(grid);
}
else if (tabId === 'advanced') {
const title = document.createElement('div');
title.className = 'od-section-title';
title.textContent = '🔧 ' + getText('advanced');
tabContent.appendChild(title);
const grid = document.createElement('div');
grid.className = 'od-grid';
const cobaltInput = odCreateInput('cobaltApiUrl', 'cobalt.tools');
const pingContainer = document.createElement('div');
pingContainer.style.display = 'flex';
pingContainer.style.alignItems = 'center';
pingContainer.style.gap = '10px';
pingContainer.style.marginTop = '10px';
const pingBtn = document.createElement('button');
pingBtn.className = 'od-btn-secondary';
pingBtn.textContent = 'Test Connection';
const statusBadge = document.createElement('div');
statusBadge.className = 'od-status-badge';
statusBadge.style.background = 'rgba(255,255,255,0.05)';
statusBadge.style.color = 'var(--od-text-secondary)';
const statusText = document.createElement('span');
statusText.textContent = 'Not tested';
statusBadge.appendChild(statusText);
pingBtn.addEventListener('click', () => {
testCobaltConnection(statusBadge, statusText);
});
pingContainer.appendChild(pingBtn);
pingContainer.appendChild(statusBadge);
const cobaltCardControl = document.createElement('div');
cobaltCardControl.appendChild(cobaltInput);
cobaltCardControl.appendChild(pingContainer);
grid.appendChild(odCreateCard('Cobalt API URL', 'External Cobalt API endpoint for fallbacks', cobaltCardControl));
const proxyInput = odCreateInput('customProxy', 'https://my-proxy.com/?url=');
grid.appendChild(odCreateCard('Custom CORS Proxy', 'Proxy for direct media bypasses', proxyInput));
const concurrentSlider = odCreateSlider('concurrentDownloads', 1, 5, '', null);
grid.appendChild(odCreateCard('Concurrent Downloads', 'Maximum simultaneous files enqueued', concurrentSlider));
const cacheBtn = document.createElement('button');
cacheBtn.className = 'od-btn-secondary';
cacheBtn.textContent = 'Clear Cache';
cacheBtn.addEventListener('click', () => {
if (typeof DownloadCache !== 'undefined' && DownloadCache.clear) {
DownloadCache.clear();
} else {
localStorage.removeItem('ksmo_youtube_base_url');
localStorage.removeItem('ksmo_youtube_decipher_steps');
localStorage.removeItem('ksmo_youtube_base_url_n');
localStorage.removeItem('ksmo_youtube_n_func');
}
showToast('Download cache cleared successfully!');
});
grid.appendChild(odCreateCard('Cache Settings', 'Clear YouTube decipher signatures cache', cacheBtn));
const debugSw = odCreateSwitch('debugMode');
grid.appendChild(odCreateCard('Debug Console Mode', 'Show advanced details in browser developer logs', debugSw));
tabContent.appendChild(grid);
}
else if (tabId === 'siteSpecific') {
const title = document.createElement('div');
title.className = 'od-section-title';
title.textContent = '🎬 ' + getText('siteSpecific');
tabContent.appendChild(title);
const grid = document.createElement('div');
grid.className = 'od-grid';
const ytCol = document.createElement('div');
ytCol.appendChild(odCreateCard('Inject Native Download Button', 'Add OmniDown button to YouTube video controls bar', odCreateSwitch('youtubeNativeButton')));
ytCol.appendChild(odCreateCard('Playlist Exporter', 'Show playlist extraction tools', odCreateSwitch('youtubePlaylist')));
ytCol.appendChild(odCreateCard('Captions Downloader', 'Allow downloading subtitles track as SRT', odCreateSwitch('youtubeSubtitles')));
grid.appendChild(odCreateCard('YouTube Settings', 'Site specific behaviors', ytCol));
const ttCol = document.createElement('div');
ttCol.appendChild(odCreateCard('For You Sidebar panel', 'Show float sidebar layout on scroll', odCreateSwitch('tiktokSidebar')));
ttCol.appendChild(odCreateCard('Network Interceptor', 'Analyze API responses for description and stats', odCreateSwitch('tiktokNetwork')));
ttCol.appendChild(odCreateCard('Auto-detect on Scroll', 'Update sidebar data dynamically on feed scroll', odCreateSwitch('tiktokAutoDetect')));
grid.appendChild(odCreateCard('TikTok Settings', 'Site specific behaviors', ttCol));
const igCol = document.createElement('div');
igCol.appendChild(odCreateCard('Carousel Media Detector', 'Search carousel for multiple photos/videos', odCreateSwitch('instagramCarousel')));
igCol.appendChild(odCreateCard('HLS Blob Fallback', 'Process blob HLS urls securely', odCreateSwitch('instagramHls')));
grid.appendChild(odCreateCard('Instagram Settings', 'Site specific behaviors', igCol));
tabContent.appendChild(grid);
}
else if (tabId === 'about') {
const title = document.createElement('div');
title.className = 'od-section-title';
title.textContent = 'ℹ️ ' + getText('about');
tabContent.appendChild(title);
const grid = document.createElement('div');
grid.className = 'od-grid';
const statsDiv = document.createElement('div');
const addStat = (label, val) => {
const row = document.createElement('div');
row.className = 'od-stats-row';
const lSpan = document.createElement('span');
lSpan.className = 'od-stats-label';
lSpan.textContent = label;
const vSpan = document.createElement('span');
vSpan.className = 'od-stats-value';
vSpan.textContent = val;
row.appendChild(lSpan);
row.appendChild(vSpan);
statsDiv.appendChild(row);
};
const tot = localStorage.getItem('omnidown_stat_total') || '0';
const today = localStorage.getItem('omnidown_stat_today') || '0';
const savedBytes = parseInt(localStorage.getItem('omnidown_stat_bytes') || '0', 10);
const savedGb = (savedBytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
addStat('Total Downloads', tot);
addStat('Downloaded Today', today);
addStat('Bandwidth Saved', savedGb);
grid.appendChild(odCreateCard('Usage Stats', 'Local activity overview', statsDiv));
const infoDiv = document.createElement('div');
infoDiv.style.fontSize = '12px';
infoDiv.style.lineHeight = '1.6';
infoDiv.style.color = 'var(--od-text-secondary)';
const v = document.createElement('div');
v.textContent = 'Version: 3.2.1';
const aut = document.createElement('div');
aut.textContent = 'Author: ksmo';
const lic = document.createElement('div');
lic.textContent = 'License: MIT';
infoDiv.appendChild(v);
infoDiv.appendChild(aut);
infoDiv.appendChild(lic);
const linksDiv = document.createElement('div');
linksDiv.style.marginTop = '10px';
linksDiv.style.display = 'flex';
linksDiv.style.gap = '10px';
const addLink = (text, href) => {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.textContent = text;
a.style.color = 'var(--od-accent)';
a.style.textDecoration = 'none';
linksDiv.appendChild(a);
};
addLink('GitHub', 'https://github.com/');
addLink('GreasyFork', 'https://greasyfork.org/');
addLink('Report Issue', 'https://github.com/');
infoDiv.appendChild(linksDiv);
grid.appendChild(odCreateCard('About OmniDown', 'Script credits and updates', infoDiv));
tabContent.appendChild(grid);
}
contentArea.appendChild(tabContent);
};
tabs.forEach(tab => {
const btn = document.createElement('button');
btn.className = 'od-nav-item';
btn.textContent = tab.label;
btn.addEventListener('click', () => {
navButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderTab(tab.id);
});
nav.appendChild(btn);
navButtons.push(btn);
});
navButtons[0].classList.add('active');
body.appendChild(nav);
body.appendChild(contentArea);
panel.appendChild(body);
const footer = document.createElement('div');
footer.className = 'od-footer';
const resetBtn = document.createElement('button');
resetBtn.className = 'od-btn-secondary';
resetBtn.textContent = getText('resetDefaults');
resetBtn.addEventListener('click', resetSettings);
const saveBtn = document.createElement('button');
saveBtn.className = 'od-btn-primary';
saveBtn.textContent = getText('saveChanges');
saveBtn.addEventListener('click', saveSettings);
footer.appendChild(resetBtn);
footer.appendChild(saveBtn);
panel.appendChild(footer);
activeDashboard.appendChild(panel);
document.body.appendChild(activeDashboard);
renderTab('appearance');
}
function closeDashboard() {
const dashboard = document.getElementById('omnidown-dashboard');
if (!dashboard) return;
dashboard.style.opacity = '0';
dashboard.querySelector('.od-panel').style.transform = 'scale(0.95) translateY(20px)';
setTimeout(() => {
dashboard.remove();
activeDashboard = null;
}, 300);
}
function saveSettings() {
applyVisuals();
showToast(getText('settingsSaved'));
closeDashboard();
if (getUIMode() === 'sidebar') {
createSidebarMode();
} else {
const sidebar = document.getElementById('omnidown-sidebar');
if (sidebar) sidebar.remove();
const overlay = document.getElementById('omnidown-sidebar-overlay');
if (overlay) overlay.remove();
// Reset TikTok sidebar state so it can reappear in popup mode
if (window.location.hostname.includes('tiktok.com')) {
tiktokState.currentVideoId = null;
tiktokState.currentUsername = null;
updateTikTokUI();
}
}
}
function resetSettings() {
if (!confirm('Are you sure you want to restore defaults?')) return;
Object.keys(SETTINGS_KEYS).forEach(key => {
const storageKey = SETTINGS_KEYS[key];
if (typeof GM_deleteValue !== 'undefined') {
GM_deleteValue(storageKey);
} else {
localStorage.removeItem(storageKey);
}
});
applyVisuals();
showToast(getText('settingsReset'));
closeDashboard();
setTimeout(() => {
openDashboard();
}, 350);
}
let activeSidebar = null;
let activeSidebarOverlay = null;
function createSidebarMode() {
if (activeSidebar) return;
// Overlay for mobile click-away
activeSidebarOverlay = document.createElement('div');
activeSidebarOverlay.id = 'omnidown-sidebar-overlay';
activeSidebarOverlay.addEventListener('click', () => {
toggleSidebar();
});
document.body.appendChild(activeSidebarOverlay);
// Main Sidebar Container
activeSidebar = document.createElement('div');
activeSidebar.id = 'omnidown-sidebar';
activeSidebar.className = 'sidebar-closed';
// 1. Header
const header = document.createElement('div');
header.className = 'sidebar-header';
const title = document.createElement('div');
title.className = 'sidebar-title';
title.textContent = 'OmniDown';
header.appendChild(title);
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.alignItems = 'center';
actions.style.gap = '8px';
// Settings / Switch Language Button
const settingsBtn = document.createElement('button');
settingsBtn.className = 'sidebar-close';
settingsBtn.title = 'Settings / Switch Language';
settingsBtn.textContent = '⚙️';
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
openDashboard();
});
actions.appendChild(settingsBtn);
const closeBtn = document.createElement('button');
closeBtn.className = 'sidebar-close';
closeBtn.setAttribute('aria-label', getText('ariaClose'));
closeBtn.appendChild(createSvgIcon('close', { width: '16', height: '16' }));
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleSidebar();
});
actions.appendChild(closeBtn);
header.appendChild(actions);
activeSidebar.appendChild(header);
// 2. Content Container
const content = document.createElement('div');
content.className = 'sidebar-content sidebar-scroll';
// Section 1: Video Info
const infoSection = document.createElement('div');
infoSection.className = 'sidebar-section';
const infoTitle = document.createElement('div');
infoTitle.className = 'section-title';
infoTitle.textContent = '📹 Video Info';
infoSection.appendChild(infoTitle);
const card = document.createElement('div');
card.className = 'video-card';
card.id = 'sidebar-video-card';
const placeholder = document.createElement('div');
placeholder.style.color = '#94a3b8';
placeholder.style.fontSize = '12px';
placeholder.style.textAlign = 'center';
placeholder.style.padding = '15px 0';
placeholder.textContent = getText('tiktokWaiting');
card.appendChild(placeholder);
infoSection.appendChild(card);
content.appendChild(infoSection);
// Section 2: Progress Area
const progressSection = document.createElement('div');
progressSection.className = 'sidebar-section';
progressSection.id = 'sidebar-progress-section';
progressSection.style.display = 'none';
const progressTitle = document.createElement('div');
progressTitle.className = 'section-title';
progressTitle.textContent = '⏳ Progress';
progressSection.appendChild(progressTitle);
const progressContainer = document.createElement('div');
progressContainer.className = 'ksmo-progress-container';
progressContainer.style.display = 'block';
const progressName = document.createElement('div');
progressName.className = 'ksmo-progress-name';
progressName.style.fontSize = '11px';
progressName.style.color = '#a29bfe';
progressName.style.marginBottom = '4px';
progressName.style.overflow = 'hidden';
progressName.style.textOverflow = 'ellipsis';
progressName.style.whiteSpace = 'nowrap';
const progressBar = document.createElement('div');
progressBar.className = 'ksmo-progress-bar';
const fill = document.createElement('div');
fill.className = 'ksmo-progress-fill';
progressBar.appendChild(fill);
const progressText = document.createElement('div');
progressText.className = 'ksmo-progress-text';
const percentText = document.createElement('span');
percentText.className = 'ksmo-progress-percent';
percentText.textContent = '0%';
const cancelBtn = document.createElement('span');
cancelBtn.className = 'ksmo-progress-cancel';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.textDecoration = 'underline';
cancelBtn.textContent = getText('cancel');
cancelBtn.addEventListener('click', () => {
DownloadQueue.clear();
progressSection.style.display = 'none';
showToast(getText('allCanceled'));
});
progressText.appendChild(percentText);
progressText.appendChild(cancelBtn);
progressContainer.appendChild(progressName);
progressContainer.appendChild(progressBar);
progressContainer.appendChild(progressText);
progressSection.appendChild(progressContainer);
content.appendChild(progressSection);
// Section 3: Download Options
const dlSection = document.createElement('div');
dlSection.className = 'sidebar-section';
const dlTitle = document.createElement('div');
dlTitle.className = 'section-title';
dlTitle.textContent = '⬇️ Download Options';
dlSection.appendChild(dlTitle);
const dlList = document.createElement('div');
dlList.id = 'sidebar-quality-list';
dlList.className = 'ksmo-quality-list';
const emptyMsg = document.createElement('div');
emptyMsg.style.textAlign = 'center';
emptyMsg.style.padding = '15px 0';
emptyMsg.style.color = '#64748b';
emptyMsg.style.fontSize = '12px';
emptyMsg.textContent = getText('noFormats');
dlList.appendChild(emptyMsg);
dlSection.appendChild(dlList);
content.appendChild(dlSection);
// Section 4: Quick Actions
const actionSection = document.createElement('div');
actionSection.className = 'sidebar-section';
const actionTitle = document.createElement('div');
actionTitle.className = 'section-title';
actionTitle.textContent = '🔗 Quick Actions';
actionSection.appendChild(actionTitle);
const grid = document.createElement('div');
grid.className = 'action-grid';
const copyLinkBtn = document.createElement('button');
copyLinkBtn.className = 'action-btn';
copyLinkBtn.id = 'sidebar-action-copy';
const copyIcon = document.createElement('span');
copyIcon.className = 'action-icon';
copyIcon.textContent = '🔗';
copyLinkBtn.appendChild(copyIcon);
const copyLabel = document.createElement('span');
copyLabel.textContent = getText('copyLink');
copyLinkBtn.appendChild(copyLabel);
copyLinkBtn.addEventListener('click', () => {
let url = window.location.href;
if (window.location.hostname.includes('tiktok.com')) {
url = buildTikTokLink() || url;
}
navigator.clipboard.writeText(url).then(() => {
showToast(getText('toastCopySuccess'));
}).catch(() => {
showToast(getText('toastCopyFail'));
});
});
grid.appendChild(copyLinkBtn);
const playlistBtn = document.createElement('button');
playlistBtn.className = 'action-btn';
playlistBtn.id = 'sidebar-action-playlist';
playlistBtn.style.opacity = '0.5';
playlistBtn.disabled = true;
const playlistIcon = document.createElement('span');
playlistIcon.className = 'action-icon';
playlistIcon.textContent = '🎬';
playlistBtn.appendChild(playlistIcon);
const playlistLabel = document.createElement('span');
playlistLabel.textContent = 'Playlist';
playlistBtn.appendChild(playlistLabel);
playlistBtn.addEventListener('click', () => {
exportPlaylist();
});
grid.appendChild(playlistBtn);
const subtitlesBtn = document.createElement('button');
subtitlesBtn.className = 'action-btn';
subtitlesBtn.id = 'sidebar-action-subtitles';
subtitlesBtn.style.opacity = '0.5';
subtitlesBtn.disabled = true;
const subIcon = document.createElement('span');
subIcon.className = 'action-icon';
subIcon.textContent = '💾';
subtitlesBtn.appendChild(subIcon);
const subLabel = document.createElement('span');
subLabel.textContent = getText('subtitles');
subtitlesBtn.appendChild(subLabel);
grid.appendChild(subtitlesBtn);
const cobaltBtn = document.createElement('button');
cobaltBtn.className = 'action-btn';
cobaltBtn.id = 'sidebar-action-cobalt';
const cobaltIcon = document.createElement('span');
cobaltIcon.className = 'action-icon';
cobaltIcon.textContent = '⚡';
cobaltBtn.appendChild(cobaltIcon);
const cobaltLabel = document.createElement('span');
cobaltLabel.textContent = getText('cobalt');
cobaltBtn.appendChild(cobaltLabel);
cobaltBtn.addEventListener('click', () => {
let url = window.location.href;
if (window.location.hostname.includes('tiktok.com')) {
url = buildTikTokLink() || url;
showTikTokCobaltConfirmation(url);
} else {
openCobaltUI(url);
}
});
grid.appendChild(cobaltBtn);
actionSection.appendChild(grid);
content.appendChild(actionSection);
// Section 5: External Tools
const fallbackSection = document.createElement('div');
fallbackSection.className = 'sidebar-section';
const fallbackTitle = document.createElement('div');
fallbackTitle.className = 'section-title';
fallbackTitle.textContent = '🌐 External Tools';
fallbackSection.appendChild(fallbackTitle);
const toolsDiv = document.createElement('div');
toolsDiv.id = 'sidebar-external-tools';
fallbackSection.appendChild(toolsDiv);
content.appendChild(fallbackSection);
activeSidebar.appendChild(content);
document.body.appendChild(activeSidebar);
}
function toggleSidebar() {
if (getUIMode() !== 'sidebar') return;
if (!activeSidebar) {
createSidebarMode();
}
const isOpen = activeSidebar.classList.contains('sidebar-open');
if (!isOpen) {
activeSidebar.classList.remove('sidebar-closed');
activeSidebar.classList.add('sidebar-open');
activeSidebarOverlay.classList.add('active');
triggerSidebarUpdate();
} else {
activeSidebar.classList.remove('sidebar-open');
activeSidebar.classList.add('sidebar-closed');
activeSidebarOverlay.classList.remove('active');
}
}
function triggerSidebarUpdate() {
if (!activeSidebar || !activeSidebar.classList.contains('sidebar-open')) return;
const extractor = getExtractor();
let info = null;
try {
info = extractor ? extractor() : null;
} catch (e) { }
updateSidebarContent(info);
}
function updateSidebarContent(info) {
if (!activeSidebar) return;
const card = document.getElementById('sidebar-video-card');
const dlList = document.getElementById('sidebar-quality-list');
const progressSec = document.getElementById('sidebar-progress-section');
const progressContainer = progressSec.querySelector('.ksmo-progress-container');
const fill = progressSec.querySelector('.ksmo-progress-fill');
const percentText = progressSec.querySelector('.ksmo-progress-percent');
// Clean quality list safely (Trusted Types compliant)
dlList.textContent = '';
if (!info) {
// Placeholder/Waiting state
card.textContent = '';
const placeholder = document.createElement('div');
placeholder.style.color = '#94a3b8';
placeholder.style.fontSize = '12px';
placeholder.style.textAlign = 'center';
placeholder.style.padding = '15px 0';
placeholder.textContent = getText('tiktokWaiting');
card.appendChild(placeholder);
const emptyMsg = document.createElement('div');
emptyMsg.style.textAlign = 'center';
emptyMsg.style.padding = '15px 0';
emptyMsg.style.color = '#64748b';
emptyMsg.style.fontSize = '12px';
emptyMsg.textContent = getText('noFormats');
dlList.appendChild(emptyMsg);
// Disable playlist / subtitles buttons
const playlistBtn = document.getElementById('sidebar-action-playlist');
if (playlistBtn) {
playlistBtn.disabled = true;
playlistBtn.style.opacity = '0.5';
}
const subtitlesBtn = document.getElementById('sidebar-action-subtitles');
if (subtitlesBtn) {
subtitlesBtn.disabled = true;
subtitlesBtn.style.opacity = '0.5';
}
return;
}
// Override generic title with TikTok description when available
if (window.location.hostname.includes('tiktok.com') && info) {
const vid = tiktokState.currentVideoId;
const data = vid ? tiktokState.videoData.get(vid) : null;
if (data && data.desc) {
info.title = data.desc;
}
}
// 1. Resolve Thumbnail
let thumbnail = null;
if (window.location.hostname.includes('youtube.com')) {
const urlParams = new URLSearchParams(window.location.search);
const ytId = urlParams.get('v');
if (ytId) {
thumbnail = `https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`;
}
} else {
const activeVideo = getActiveVideoElement();
if (activeVideo) {
thumbnail = activeVideo.getAttribute('poster') || activeVideo.poster;
}
if (!thumbnail && window.location.hostname.includes('tiktok.com')) {
const article = getVisibleArticle();
if (article) {
const img = article.querySelector('img[src*="cover"], [class*="DivContainer"] img, [class*="VideoCard"] img, img');
if (img) thumbnail = img.src;
}
}
if (!thumbnail) {
const img = document.querySelector('article img, [class*="cover"] img, video[poster]');
if (img) {
thumbnail = img.getAttribute('poster') || img.src;
}
}
}
// 2. Populate Video Card
card.textContent = '';
if (thumbnail) {
const img = document.createElement('img');
img.className = 'video-thumb';
img.src = thumbnail;
img.alt = info.title;
card.appendChild(img);
}
const titleDiv = document.createElement('div');
titleDiv.className = 'video-title';
titleDiv.textContent = info.title;
card.appendChild(titleDiv);
const metaDiv = document.createElement('div');
metaDiv.className = 'video-meta';
// Author
let author = 'OmniDown';
let authorUrl = null;
if (window.location.hostname.includes('youtube.com')) {
const ytAuthorEl = document.querySelector('#upload-info #channel-name a') || document.querySelector('ytd-video-owner-renderer a');
if (ytAuthorEl) {
author = ytAuthorEl.textContent.trim();
authorUrl = ytAuthorEl.href;
}
} else if (window.location.hostname.includes('tiktok.com')) {
const vid = tiktokState.currentVideoId;
const data = tiktokState.videoData.get(vid);
author = tiktokState.currentUsername || (data ? data.username : null) || 'user';
authorUrl = `https://www.tiktok.com/@${author.replace(/^@/, '')}`;
} else if (window.location.hostname.includes('instagram.com')) {
const igAuthorEl = document.querySelector('header a[class*="x1i10hfl"]');
if (igAuthorEl) {
author = igAuthorEl.textContent.trim();
authorUrl = igAuthorEl.href;
}
}
const authorLink = document.createElement('a');
authorLink.textContent = author.startsWith('@') ? author : '@' + author;
if (authorUrl) {
authorLink.href = authorUrl;
authorLink.target = '_blank';
} else {
authorLink.href = 'javascript:void(0)';
}
metaDiv.appendChild(authorLink);
// Stats
let statsText = '';
if (window.location.hostname.includes('youtube.com')) {
const viewsEl = document.querySelector('#info-text span') || document.querySelector('#metadata-line span');
if (viewsEl) {
statsText = ' • ' + viewsEl.textContent.trim();
}
} else if (window.location.hostname.includes('tiktok.com')) {
const vid = tiktokState.currentVideoId;
const data = tiktokState.videoData.get(vid);
if (data && data.stats) {
statsText = ` • 👁 ${formatNumber(data.stats.playCount)} • ❤ ${formatNumber(data.stats.diggCount)} • 💬 ${formatNumber(data.stats.commentCount)}`;
}
}
if (statsText) {
const statsSpan = document.createElement('span');
statsSpan.textContent = statsText;
metaDiv.appendChild(statsSpan);
}
card.appendChild(metaDiv);
// 3. Populate Download Formats List
if (info.formats && info.formats.length > 0) {
info.formats.forEach(f => {
renderQualityRow(f, info, progressSec, fill, percentText, dlList);
});
} else if (window.location.hostname.includes('spotify.com')) {
const spotifyMessage = document.createElement('div');
spotifyMessage.style.textAlign = 'center';
spotifyMessage.style.padding = '20px 0';
spotifyMessage.style.color = '#a29bfe';
spotifyMessage.style.fontSize = '12px';
spotifyMessage.textContent = getText('spotifyRedirect');
dlList.appendChild(spotifyMessage);
} else {
const emptyMsg = document.createElement('div');
emptyMsg.style.textAlign = 'center';
emptyMsg.style.padding = '15px 0';
emptyMsg.style.color = '#64748b';
emptyMsg.style.fontSize = '12px';
emptyMsg.textContent = getText('noFormats');
dlList.appendChild(emptyMsg);
}
// 4. Update Quick Actions (Playlist & Subtitles)
const playlistBtn = document.getElementById('sidebar-action-playlist');
if (playlistBtn) {
const isPlaylist = window.location.href.includes('list=');
playlistBtn.disabled = !isPlaylist;
playlistBtn.style.opacity = isPlaylist ? '1' : '0.5';
}
const subtitlesBtn = document.getElementById('sidebar-action-subtitles');
if (subtitlesBtn) {
const subTrack = info.formats ? info.formats.find(f => f.isSubtitle) : null;
if (subTrack) {
subtitlesBtn.disabled = false;
subtitlesBtn.style.opacity = '1';
const newBtn = subtitlesBtn.cloneNode(true);
newBtn.addEventListener('click', () => {
progressSec.style.display = 'flex';
fill.style.width = '0%';
percentText.textContent = '0%';
const sanitizedTitle = info.title.replace(/[\\/:*?"<>|]/g, '_');
GM_xmlhttpRequest({
method: 'GET',
url: subTrack.url,
onload: (resp) => {
const srt = convertXmlToSrt(resp.responseText);
const blob = new Blob([srt], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${sanitizedTitle}_${subTrack.quality}.srt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
progressSec.style.display = 'none';
showToast(getText('toastSubtitlesSuccess'));
},
onerror: () => {
progressSec.style.display = 'none';
showToast(getText('toastSubtitlesFail'));
}
});
});
subtitlesBtn.parentNode.replaceChild(newBtn, subtitlesBtn);
} else {
subtitlesBtn.disabled = true;
subtitlesBtn.style.opacity = '0.5';
}
}
// 5. Populate Fallback Tools
const toolsDiv = document.getElementById('sidebar-external-tools');
if (toolsDiv) {
toolsDiv.textContent = '';
const host = window.location.hostname;
const addBtn = (name, url) => {
const btn = document.createElement('button');
btn.className = 'fallback-btn';
btn.textContent = name;
btn.addEventListener('click', () => {
let targetUrl = window.location.href;
if (host.includes('tiktok.com')) {
targetUrl = buildTikTokLink() || targetUrl;
}
const finalUrl = url.replace('{url}', encodeURIComponent(targetUrl));
window.open(finalUrl, '_blank');
});
toolsDiv.appendChild(btn);
};
if (host.includes('tiktok.com')) {
addBtn('SnapTik', 'https://snaptik.app/');
addBtn('SSSTik', 'https://ssstik.io/');
} else if (host.includes('instagram.com')) {
addBtn('SaveInsta', 'https://saveinsta.app/');
addBtn('SaveFrom', 'https://savefrom.net/?url={url}');
} else if (host.includes('youtube.com')) {
addBtn('SaveFrom', 'https://savefrom.net/?url={url}');
} else {
addBtn('SaveFrom', 'https://savefrom.net/?url={url}');
}
}
}
function setupTikTokObserver() {
// Create sidebar immediately if on TikTok
if (isTikTokFYP() && !document.getElementById('ksmo-tt-sidebar')) {
createTikTokSidebar();
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio > 0.3) {
const article = entry.target;
const vid = getVideoIdFromArticle(article);
const user = getUsernameFromArticle(article);
// If we have a fresh console-detected video ID, only trust DOM scraping
// from the article that matches this ID.
const isConsoleIdFresh = tiktokState.lastConsoleIdTime && (Date.now() - tiktokState.lastConsoleIdTime < 4000);
if (isConsoleIdFresh && tiktokState.currentVideoId) {
if (vid && vid !== tiktokState.currentVideoId) {
continue; // Ignore this adjacent article
}
}
let changed = false;
if (vid && vid !== tiktokState.currentVideoId) {
tiktokState.currentVideoId = vid;
changed = true;
}
if (user && user !== tiktokState.currentUsername) {
tiktokState.currentUsername = user;
changed = true;
}
if (!user && vid && tiktokState.videoData.has(vid)) {
const cachedUser = tiktokState.videoData.get(vid).username;
if (cachedUser && cachedUser !== tiktokState.currentUsername) {
tiktokState.currentUsername = cachedUser;
changed = true;
}
}
if (changed) {
updateTikTokUI();
}
}
}
}, { threshold: [0.3, 0.5, 0.75, 1.0] });
const observeArticles = () => {
document.querySelectorAll('article, [data-e2e="recommend-list-item"], [class*="DivItemContainer"]').forEach(article => {
if (!article._ttObserver) {
article._ttObserver = true;
observer.observe(article);
}
});
};
observeArticles();
// More frequent observation
const observeInterval = setInterval(observeArticles, 500);
const mo = new MutationObserver((mutations) => {
let needsUpdate = false;
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.tagName === 'ARTICLE' || node.matches?.('[data-e2e="recommend-list-item"]') || node.matches?.('[class*="DivItemContainer"]')) {
observer.observe(node);
node._ttObserver = true;
needsUpdate = true;
}
if (node.querySelectorAll) {
node.querySelectorAll('article, [data-e2e="recommend-list-item"], [class*="DivItemContainer"]').forEach(article => {
if (!article._ttObserver) {
observer.observe(article);
article._ttObserver = true;
needsUpdate = true;
}
});
}
}
});
});
if (needsUpdate && isTikTokFYP()) {
updateTikTokUI();
}
});
mo.observe(document.body, { childList: true, subtree: true });
// Also try to extract from URL periodically (on all TikTok pages)
setInterval(() => {
if (window.location.hostname.includes('tiktok.com')) {
const urlId = getVideoIdFromURL();
if (urlId && urlId !== tiktokState.currentVideoId) {
tiktokState.currentVideoId = urlId;
const urlMatch = window.location.pathname.match(/\/@([^\/]+)/);
if (urlMatch) {
tiktokState.currentUsername = '@' + urlMatch[1];
}
updateTikTokUI();
}
}
}, 500);
}
function startTikTokPolling() {
setInterval(() => {
const article = getVisibleArticle();
if (!article) return;
const vid = getVideoIdFromArticle(article);
const user = getUsernameFromArticle(article);
// If we have a fresh console-detected video ID, only trust DOM scraping
// from the article that matches this ID.
const isConsoleIdFresh = tiktokState.lastConsoleIdTime && (Date.now() - tiktokState.lastConsoleIdTime < 4000);
if (isConsoleIdFresh && tiktokState.currentVideoId) {
if (vid && vid !== tiktokState.currentVideoId) {
return; // Ignore this adjacent article
}
}
let changed = false;
if (vid && vid !== tiktokState.currentVideoId) {
tiktokState.currentVideoId = vid;
changed = true;
}
if (user && user !== tiktokState.currentUsername) {
tiktokState.currentUsername = user;
changed = true;
}
if (tiktokState.currentVideoId && !tiktokState.currentUsername && tiktokState.videoData.has(tiktokState.currentVideoId)) {
const cachedUser = tiktokState.videoData.get(tiktokState.currentVideoId).username;
if (cachedUser && cachedUser !== tiktokState.currentUsername) {
tiktokState.currentUsername = cachedUser;
changed = true;
}
}
// Fallback: try to get username from page URL or DOM
if (!tiktokState.currentUsername) {
const urlMatch = window.location.pathname.match(/\/@([^\/]+)/);
if (urlMatch) {
tiktokState.currentUsername = '@' + urlMatch[1];
changed = true;
}
}
// AGGRESSIVE: Scan entire DOM for video IDs if we still don't have one
if (!tiktokState.currentVideoId) {
const allText = document.body.innerText || document.body.textContent || '';
const idMatches = allText.match(/\b(\d{19})\b/g);
if (idMatches) {
for (const id of idMatches) {
if (id.startsWith('7') || id.startsWith('6') || id.startsWith('5')) {
tiktokState.currentVideoId = id;
changed = true;
break;
}
}
}
}
// AGGRESSIVE: Scan all links for /video/ pattern
if (!tiktokState.currentVideoId) {
const links = document.querySelectorAll('a[href*="/video/"]');
for (const link of links) {
const match = link.href.match(/\/video\/(\d+)/);
if (match && /^\d{19}$/.test(match[1])) {
tiktokState.currentVideoId = match[1];
changed = true;
break;
}
}
}
if (changed) {
updateTikTokUI();
}
}, 300);
}
function initTikTokForYouSupport() {
if (window.self !== window.top) return;
const myWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
// ===== INTERCEPTAÇÃO DO CONSOLE (TikTok Copier v6) =====
const consoleMethods = ['log', 'info', 'debug', 'warn'];
consoleMethods.forEach(method => {
if (!myWin.console || !myWin.console[method]) return;
const original = myWin.console[method];
myWin.console[method] = function (...args) {
for (const arg of args) {
processConsoleArg(arg);
}
// Also check for PlayerStart pattern: {id: '1234567890123456789'}
const fullStr = args.map(a => {
if (typeof a === 'object' && a !== null) {
try { return JSON.stringify(a); } catch (e) { return String(a); }
}
return String(a);
}).join(' ');
const playerIdMatch = fullStr.match(/['"]id['"]\s*:\s*['"](\d{19})['"]/);
if (playerIdMatch) {
updateVideoIdFromConsole(playerIdMatch[1]);
}
return original.apply(this, args);
};
});
const originalFetch = myWin.fetch;
myWin.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const url = args[0];
const isTikTokApi = typeof url === 'string' && (
url.includes('tiktok.com/api/') ||
url.includes('tiktok.com/aweme/') ||
url.includes('tiktok.com/feed/') ||
url.includes('tiktok.com/recommend/') ||
url.includes('tiktok.com/related/') ||
url.includes('tiktok.com/item/') ||
url.includes('tiktok.com/music/')
);
if (isTikTokApi) {
// Clone response to avoid "body already read" errors
const clonedResponse = response.clone();
clonedResponse.text().then(text => {
// Process the text for video data
parseTikTokItemList(text);
// Extract video IDs and usernames from response
const videoMatches = [...text.matchAll(/"id":"(\d{19})"/g)];
const userMatches = [...text.matchAll(/"uniqueId":"([^"]+)"/g)];
for (let i = 0; i < videoMatches.length; i++) {
const vid = videoMatches[i][1];
const user = userMatches[i] ? userMatches[i][1] : null;
if (user) {
tiktokState.videoData.set(vid, {
username: '@' + user.replace(/^@/, ''),
timestamp: Date.now()
});
}
}
// Update current video if we have data for it
if (tiktokState.currentVideoId && tiktokState.videoData.has(tiktokState.currentVideoId)) {
const data = tiktokState.videoData.get(tiktokState.currentVideoId);
if (data.username && data.username !== tiktokState.currentUsername) {
tiktokState.currentUsername = data.username;
updateTikTokUI();
}
}
// Try to get first video ID from response
const idMatch = text.match(/"id":"(\d{19})"/);
if (idMatch && idMatch[1] !== tiktokState.currentVideoId) {
tiktokState.currentVideoId = idMatch[1];
updateTikTokUI();
}
}).catch(() => {});
}
} catch (e) { }
return response;
};
const originalOpen = myWin.XMLHttpRequest.prototype.open;
const originalSend = myWin.XMLHttpRequest.prototype.send;
myWin.XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._url = url;
return originalOpen.call(this, method, url, ...rest);
};
myWin.XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', function () {
try {
if (this._url && this._url.includes('tiktok.com')) {
const text = this.responseText;
parseTikTokItemList(text);
// Extract video IDs and usernames
const videoMatches = [...text.matchAll(/"id":"(\d{19})"/g)];
const userMatches = [...text.matchAll(/"uniqueId":"([^"]+)"/g)];
for (let i = 0; i < videoMatches.length; i++) {
const vid = videoMatches[i][1];
const user = userMatches[i] ? userMatches[i][1] : null;
if (user) {
tiktokState.videoData.set(vid, {
username: '@' + user.replace(/^@/, ''),
timestamp: Date.now()
});
}
}
// Update current video if we have data for it
if (tiktokState.currentVideoId && tiktokState.videoData.has(tiktokState.currentVideoId)) {
const data = tiktokState.videoData.get(tiktokState.currentVideoId);
if (data.username && data.username !== tiktokState.currentUsername) {
tiktokState.currentUsername = data.username;
updateTikTokUI();
}
}
const idMatch = text.match(/"id":"(\d{19})"/);
if (idMatch && idMatch[1] !== tiktokState.currentVideoId) {
tiktokState.currentVideoId = idMatch[1];
updateTikTokUI();
}
}
} catch (e) { }
});
return originalSend.call(this, ...args);
};
setupTikTokObserver();
startTikTokPolling();
// Extract from SSR data (window.__INITIAL_STATE__ or __NEXT_DATA__)
setTimeout(() => {
try {
// Try __INITIAL_STATE__
if (window.__INITIAL_STATE__) {
const stateStr = JSON.stringify(window.__INITIAL_STATE__);
const idMatch = stateStr.match(/"id":"(\d{19})"/);
const userMatch = stateStr.match(/"uniqueId":"([^"]+)"/);
if (idMatch) {
tiktokState.currentVideoId = idMatch[1];
if (userMatch) tiktokState.currentUsername = '@' + userMatch[1];
updateTikTokUI();
}
}
// Try __NEXT_DATA__
if (window.__NEXT_DATA__) {
const nextStr = JSON.stringify(window.__NEXT_DATA__);
const idMatch = nextStr.match(/"id":"(\d{19})"/);
const userMatch = nextStr.match(/"uniqueId":"([^"]+)"/);
if (idMatch && !tiktokState.currentVideoId) {
tiktokState.currentVideoId = idMatch[1];
if (userMatch) tiktokState.currentUsername = '@' + userMatch[1];
updateTikTokUI();
}
}
} catch (e) { }
}, 500);
// Listen for TikTok player events (PlayerStart logs IDs in console)
// Also try to extract from video elements directly
const videoObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
const videos = node.tagName === 'VIDEO' ? [node] : node.querySelectorAll?.('video') || [];
videos.forEach(video => {
if (!video._ttTracked) {
video._ttTracked = true;
// Try to get ID from video attributes or parent
const parent = video.closest('[data-item-id]') || video.closest('[data-video-id]') || video.parentElement;
if (parent) {
const id = parent.getAttribute('data-item-id') || parent.getAttribute('data-video-id');
if (id && /^\d{19}$/.test(id)) {
tiktokState.currentVideoId = id;
updateTikTokUI();
}
}
}
});
}
});
});
});
videoObserver.observe(document.body, { childList: true, subtree: true });
// Force initial UI update
setTimeout(() => {
updateTikTokUI();
}, 1000);
// Also scan existing videos immediately
document.querySelectorAll('video').forEach(video => {
if (!video._ttTracked) {
video._ttTracked = true;
const parent = video.closest('[data-item-id]') || video.closest('[data-video-id]') || video.parentElement;
if (parent) {
const id = parent.getAttribute('data-item-id') || parent.getAttribute('data-video-id');
if (id && /^\d{19}$/.test(id)) {
tiktokState.currentVideoId = id;
updateTikTokUI();
}
}
}
});
}
function processConsoleArg(arg) {
if (arg === null || arg === undefined) return;
if (typeof arg === 'string') {
const idMatch = arg.match(/id['"]?\s*[:=]\s*['"](\d{19})['"]/);
if (idMatch) {
updateVideoIdFromConsole(idMatch[1]);
}
return;
}
if (typeof arg === 'object') {
const props = ['id', 'videoId', 'itemId'];
for (const prop of props) {
if (arg[prop] && /^\d{19}$/.test(String(arg[prop]))) {
updateVideoIdFromConsole(String(arg[prop]));
}
}
try {
const str = JSON.stringify(arg);
const match = str.match(/"id":"(\d{19})"/);
if (match) {
updateVideoIdFromConsole(match[1]);
}
} catch (e) { }
}
}
function updateVideoIdFromConsole(id) {
if (!id || id === tiktokState.currentVideoId) return;
tiktokState.currentVideoId = id;
tiktokState.lastConsoleIdTime = Date.now();
if (tiktokState.videoData.has(id)) {
tiktokState.currentUsername = tiktokState.videoData.get(id).username;
}
updateTikTokUI();
}
const TRANSLATIONS = {
en: {
title: 'HD Video Downloader',
download: 'Download',
fallback: 'Fallback',
cobalt: 'Cobalt',
copyLink: 'Copy Link',
exportPlaylist: 'Export Playlist',
subtitles: 'Subtitles',
subtitleTrack: 'Subtitle Track',
noFormats: 'No direct download formats found. Use fallback tools below.',
spotifyRedirect: 'Spotify tracks require external conversion. Directing to spotifydown.com below.',
externalWarning: '⚠️ External services may contain ads and are not controlled by this script.',
toastCopySuccess: 'Link copied to clipboard',
toastCopyFail: 'Failed to copy link',
toastYtBlocked: 'YouTube is blocking direct downloads. Use fallback.',
toastNoVideo: 'No video found on this page.',
toastSubtitlesSuccess: 'Subtitles downloaded successfully',
toastSubtitlesFail: 'Subtitles download failed',
toastDownloadSuccess: 'Download completed successfully',
toastDirectFail: 'Direct download failed. Click "Fallback" below to use external tool.',
toastDirectFailConfirm: 'Direct download failed. Redirect to external downloader service?',
toastHlsDetected: 'HLS Stream detected. Redirecting to fallback...',
toastYtCipherFail: 'YouTube signature cipher decryption failed. Redirecting to fallback...',
cancel: 'Cancel',
allCanceled: 'All downloads canceled',
floatingButtonLabel: 'Open video download options',
nativeYtDlTitle: 'Download Video (Ctrl+Alt+Shift+D)',
cobaltConfirmTitle: 'Cobalt Downloader',
cobaltConfirmDesc: 'Did you copy the share link for this video on TikTok?<br><br>We recommend using the copied share link (Ctrl+C) for the best results on Cobalt.',
cobaltConfirmYes: 'Yes, use Ctrl+C link',
cobaltConfirmNo: 'No, use detected link',
cobaltPromptNotTikTok: 'The clipboard link does not look like a TikTok link. Paste the video link here:',
cobaltPromptFallback: 'Please paste the copied TikTok link here:',
toastSettingEnabled: 'enabled',
toastSettingDisabled: 'disabled',
toastReloadPage: 'Reload the page.',
langSelectTitle: 'OmniDown Language / Idioma',
langSelectDesc: 'Select your language / Selecione o idioma:',
langSelectConfirm: 'Save / Salvar',
currentLabel: 'Current',
deciphering: 'Deciphering...',
cipherBlocked: 'Cipher Blocked',
adaptiveNoAudio: 'Adaptive (No Audio)',
audioOnly: 'Audio Only',
muxed: 'Muxed',
menuOpenMenu: '📥 Open Download Menu',
menuDownloadCobalt: '⚡ Download Media (Cobalt)',
menuChangeLang: '🌐 Change Language / Alterar Idioma / 更改语言',
menuSetCobaltUrl: '⚙️ Set Cobalt API URL',
menuSetProxy: '⚙️ Set Custom CORS Proxy',
menuToggleBtn: '🔘 Toggle Floating Button UI',
menuToggleFallback: '🔘 Toggle Auto-Fallback',
menuToggleHotkey: '🔘 Toggle Keyboard Shortcut',
toastPlaylistExported: 'Exported {count} playlist links.',
toastNoPlaylist: 'No playlist links found on page.',
promptSetProxy: 'Enter custom CORS proxy URL prefix (e.g. https://myproxy.com/?url=) or leave empty to clear:',
toastProxyConfigured: 'Custom CORS proxy configured.',
toastProxyCleared: 'Custom CORS proxy cleared.',
ariaMinimize: 'Minimize',
ariaClose: 'Close',
ariaSelectService: 'Select Service',
ariaFormatDownload: 'Download format {quality}',
ariaCobaltRow: 'Download with Cobalt',
promptSetCobaltUrl: 'Enter Cobalt API URL (e.g. cobalt.tools):',
labelTiktok: 'TikTok Video',
labelInstagram: 'Instagram Video',
labelTwitter: 'Twitter Video',
labelFacebook: 'Facebook Video',
labelThreads: 'Threads Video',
labelDouyin: 'Douyin Video',
labelXhs: 'XiaoHongShu Video',
labelTed: 'TED Video',
labelSnapchat: 'Snapchat Video',
labelPinterest: 'Pinterest Video',
labelSpotify: 'Spotify Music',
labelYoutube: 'YouTube Video',
labelHlsBlob: 'HLS Stream (Direct Download Unsupported)',
labelHtml5Video: 'HTML5 Video',
toastCobaltApiUrl: 'Cobalt API URL: {url}'
},
pt: {
title: 'Download de Vídeo HD',
download: 'Baixar',
fallback: 'Fallback',
cobalt: 'Cobalt',
copyLink: 'Copiar Link',
exportPlaylist: 'Exportar Playlist',
subtitles: 'Legendas',
subtitleTrack: 'Faixa de Legendas',
noFormats: 'Nenhum formato de download direto encontrado. Use as ferramentas de fallback abaixo.',
spotifyRedirect: 'Músicas do Spotify requerem conversão externa. Direcionando para spotifydown.com abaixo.',
externalWarning: '⚠️ Serviços externos podem conter anúncios e não são controlados por este script.',
toastCopySuccess: 'Link copiado para a área de transferência',
toastCopyFail: 'Falha ao copiar o link',
toastYtBlocked: 'O YouTube está bloqueando downloads diretos. Use o fallback.',
toastNoVideo: 'Nenhum vídeo encontrado nesta página.',
toastSubtitlesSuccess: 'Legendas baixadas com sucesso',
toastSubtitlesFail: 'Falha ao baixar legendas',
toastDownloadSuccess: 'Download concluído com sucesso',
toastDirectFail: 'Download direto falhou. Clique em "Fallback" abaixo para usar ferramenta externa.',
toastDirectFailConfirm: 'O download direto falhou. Redirecionar para o serviço de download externo?',
toastHlsDetected: 'Stream HLS detectado. Redirecionando para o fallback...',
toastYtCipherFail: 'Decifração da assinatura do YouTube falhou. Redirecionando para o fallback...',
cancel: 'Cancelar',
allCanceled: 'Todos os downloads cancelados',
floatingButtonLabel: 'Abrir opções de download de vídeo',
nativeYtDlTitle: 'Baixar Vídeo (Ctrl+Alt+Shift+D)',
cobaltConfirmTitle: 'Downloader Cobalt',
cobaltConfirmDesc: 'Você copiou o link de compartilhamento deste vídeo no TikTok?<br><br>Recomendamos usar o link copiado (Ctrl+C) para obter o melhor resultado no Cobalt.',
cobaltConfirmYes: 'Sim, usar link do Ctrl+C',
cobaltConfirmNo: 'Não, usar link detectado',
cobaltPromptNotTikTok: 'O link na área de transferência não parece ser do TikTok. Cole o link do vídeo aqui:',
cobaltPromptFallback: 'Por favor, cole o link copiado do TikTok aqui:',
toastSettingEnabled: 'ativado',
toastSettingDisabled: 'desativado',
toastReloadPage: 'Recarregue a página.',
langSelectTitle: 'Idioma do OmniDown',
langSelectDesc: 'Selecione o seu idioma de preferência:',
langSelectConfirm: 'Salvar',
currentLabel: 'Atual',
deciphering: 'Decifrando...',
cipherBlocked: 'Assinatura Bloqueada',
adaptiveNoAudio: 'Adaptativo (Sem Áudio)',
audioOnly: 'Apenas Áudio',
muxed: 'Misto',
menuOpenMenu: '📥 Abrir Painel de Download',
menuDownloadCobalt: '⚡ Baixar Mídia (Cobalt)',
menuChangeLang: '🌐 Alterar Idioma / Change Language / 更改语言',
menuSetCobaltUrl: '⚙️ Definir URL da API Cobalt',
menuSetProxy: '⚙️ Definir Proxy CORS',
menuToggleBtn: '🔘 Alternar Botão Flutuante',
menuToggleFallback: '🔘 Alternar Auto-Fallback',
menuToggleHotkey: '🔘 Alternar Atalho de Teclado',
toastPlaylistExported: 'Exportado {count} links da playlist.',
toastNoPlaylist: 'Nenhum link de playlist encontrado na página.',
promptSetProxy: 'Insira o prefixo de URL do proxy CORS personalizado (ex: https://meuproxy.com/?url=) ou deixe em branco para limpar:',
toastProxyConfigured: 'Proxy CORS personalizado configurado.',
toastProxyCleared: 'Proxy CORS personalizado limpo.',
ariaMinimize: 'Minimizar',
ariaClose: 'Fechar',
ariaSelectService: 'Selecionar Serviço',
ariaFormatDownload: 'Baixar formato {quality}',
ariaCobaltRow: 'Baixar com Cobalt',
promptSetCobaltUrl: 'Insira a URL da API Cobalt (ex: cobalt.tools):',
labelTiktok: 'Vídeo do TikTok',
labelInstagram: 'Vídeo do Instagram',
labelTwitter: 'Vídeo do Twitter',
labelFacebook: 'Vídeo do Facebook',
labelThreads: 'Vídeo do Threads',
labelDouyin: 'Vídeo do Douyin',
labelXhs: 'Vídeo do XiaoHongShu',
labelTed: 'Vídeo do TED',
labelSnapchat: 'Vídeo do Snapchat',
labelPinterest: 'Vídeo do Pinterest',
labelSpotify: 'Música do Spotify',
labelYoutube: 'Vídeo do YouTube',
labelHlsBlob: 'Stream HLS (Download Direto Não Suportado)',
labelHtml5Video: 'Vídeo HTML5',
toastCobaltApiUrl: 'URL da API Cobalt: {url}'
},
'zh-CN': {
title: 'HD 视频下载器',
download: '下载',
fallback: '备用下载',
cobalt: 'Cobalt',
copyLink: '复制链接',
exportPlaylist: '导出播放列表',
subtitles: '字幕',
subtitleTrack: '字幕轨',
noFormats: '未找到直接下载格式。请使用下方备用工具。',
spotifyRedirect: 'Spotify 歌曲需要外部转换。正在转向下方 spotifydown.com。',
externalWarning: '⚠️ 外部服务可能包含广告,且不受本脚本控制。',
toastCopySuccess: '链接已复制到剪贴板',
toastCopyFail: '复制链接失败',
toastYtBlocked: 'YouTube 正在阻止直接下载。请使用备用下载。',
toastNoVideo: '此页面未找到视频。',
toastSubtitlesSuccess: '字幕下载成功',
toastSubtitlesFail: '字幕下载失败',
toastDownloadSuccess: '下载完成',
toastDirectFail: '直接下载失败。点击下方“备用下载”使用外部工具。',
toastDirectFailConfirm: '直接下载失败。是否重定向到外部下载服务?',
toastHlsDetected: '检测到 HLS 流。正在转向备用下载...',
toastYtCipherFail: 'YouTube 签名解密失败。正在转向备用下载...',
cancel: '取消',
allCanceled: '所有下载已取消',
floatingButtonLabel: '打开视频下载选项',
nativeYtDlTitle: '下载视频 (Ctrl+Alt+Shift+D)',
cobaltConfirmTitle: 'Cobalt 下载器',
cobaltConfirmDesc: '您是否已复制此 TikTok 视频的分享链接?<br><br>建议使用已复制的分享链接 (Ctrl+C) 以在 Cobalt 中获得最佳下载效果。',
cobaltConfirmYes: '是的,使用剪贴板链接',
cobaltConfirmNo: '不用,使用检测到的链接',
cobaltPromptNotTikTok: '剪贴板中的链接似乎不是 TikTok 链接。请在此处粘贴视频链接:',
cobaltPromptFallback: '请在此处粘贴复制的 TikTok 链接:',
toastSettingEnabled: '已启用',
toastSettingDisabled: '已禁用',
toastReloadPage: '请刷新页面。',
langSelectTitle: '选择语言',
langSelectDesc: '请选择您的 OmniDown 首选语言:',
langSelectConfirm: '保存',
currentLabel: '当前',
deciphering: '正在解密...',
cipherBlocked: '签名受阻',
adaptiveNoAudio: '自适应 (无音频)',
audioOnly: '仅音频',
muxed: '混合',
menuOpenMenu: '📥 打开下载面板',
menuDownloadCobalt: '⚡ 使用 Cobalt 下载',
menuChangeLang: '🌐 切换语言 / Alterar Idioma / Change Language',
menuSetCobaltUrl: '⚙️ 设置 Cobalt API 地址',
menuSetProxy: '⚙️ 设置自定义 CORS 代理',
menuToggleBtn: '🔘 开启/关闭浮动按钮',
menuToggleFallback: '🔘 开启/关闭自动备用下载',
menuToggleHotkey: '🔘 开启/关闭快捷键',
toastPlaylistExported: '已导出 {count} 个播放列表链接。',
toastNoPlaylist: '页面上未找到播放列表链接。',
promptSetProxy: '输入自定义 CORS 代理 URL 前缀(例如 https://myproxy.com/?url=)或留空以清除:',
toastProxyConfigured: '已配置自定义 CORS 代理。',
toastProxyCleared: '已清除自定义 CORS 代理。',
ariaMinimize: '最小化',
ariaClose: '关闭',
ariaSelectService: '选择服务',
ariaFormatDownload: '下载格式 {quality}',
ariaCobaltRow: '使用 Cobalt 下载',
promptSetCobaltUrl: '输入 Cobalt API 地址(例如 cobalt.tools):',
labelTiktok: 'TikTok 视频',
labelInstagram: 'Instagram 视频',
labelTwitter: 'Twitter 视频',
labelFacebook: 'Facebook 视频',
labelThreads: 'Threads 视频',
labelDouyin: '抖音视频',
labelXhs: '小红书视频',
labelTed: 'TED 视频',
labelSnapchat: 'Snapchat 视频',
labelPinterest: 'Pinterest 视频',
labelSpotify: 'Spotify 音乐',
labelYoutube: 'YouTube 视频',
labelHlsBlob: 'HLS 流 (不支持直接下载)',
labelHtml5Video: 'HTML5 视频',
toastCobaltApiUrl: 'Cobalt API 地址: {url}'
},
'zh-TW': {
title: 'HD 影片下載器',
download: '下載',
fallback: '備用下載',
cobalt: 'Cobalt',
copyLink: '複製連結',
exportPlaylist: '匯出播放清單',
subtitles: '字幕',
subtitleTrack: '字幕軌',
noFormats: '未找到直接下載格式。請使用下方備用工具。',
spotifyRedirect: 'Spotify 歌曲需要外部轉換。正在轉向下方 spotifydown.com。',
externalWarning: '⚠️ 外部服務可能包含廣告,且不受本腳本控制。',
toastCopySuccess: '連結已複製到剪貼簿',
toastCopyFail: '複製連結失敗',
toastYtBlocked: 'YouTube 正在阻止直接下載。請使用備用下載。',
toastNoVideo: '此頁面未找到影片。',
toastSubtitlesSuccess: '字幕下載成功',
toastSubtitlesFail: '字幕下載失敗',
toastDownloadSuccess: '下載完成',
toastDirectFail: '直接下載失敗。點擊下方「備用下載」使用外部工具。',
toastDirectFailConfirm: '直接下載失敗。是否重新導向到外部下載服務?',
toastHlsDetected: '偵測到 HLS 流。正在轉向備用下載...',
toastYtCipherFail: 'YouTube 簽名解密失敗。正在轉向備用下載...',
cancel: '取消',
allCanceled: '所有下載已取消',
floatingButtonLabel: '打開影片下載選項',
nativeYtDlTitle: '下載影片 (Ctrl+Alt+Shift+D)',
cobaltConfirmTitle: 'Cobalt 下載器',
cobaltConfirmDesc: '您是否已複製此 TikTok 影片的分享連結?<br><br>建議使用已複製 of 分享連結 (Ctrl+C) 以在 Cobalt 中獲得最佳下載效果。',
cobaltConfirmYes: '是的,使用剪貼簿連結',
cobaltConfirmNo: '不用,使用偵測到的連結',
cobaltPromptNotTikTok: '剪貼簿中的連結似乎不是 TikTok 連結。請在此處貼上影片連結:',
cobaltPromptFallback: '請在此處貼上複製 of TikTok 連結:',
toastSettingEnabled: '已啟用',
toastSettingDisabled: '已禁用',
toastReloadPage: '請重新整理頁面。',
langSelectTitle: '選擇語言',
langSelectDesc: '請選擇您的 OmniDown 首選語言:',
langSelectConfirm: '儲存',
currentLabel: '目前',
deciphering: '正在解密...',
cipherBlocked: '簽名受阻',
adaptiveNoAudio: '自適應 (無音訊)',
audioOnly: '僅音訊',
muxed: '混合',
menuOpenMenu: '📥 打開下載面板',
menuDownloadCobalt: '⚡ 使用 Cobalt 下載',
menuChangeLang: '🌐 切換語言 / Alterar Idioma / Change Language',
menuSetCobaltUrl: '⚙️ 設定 Cobalt API 地址',
menuSetProxy: '⚙️ 設定自訂 CORS 代理',
menuToggleBtn: '🔘 開啟/關閉影片按鈕',
menuToggleFallback: '🔘 開啟/關閉自動備用下載',
menuToggleHotkey: '🔘 開啟/關閉快捷鍵',
toastPlaylistExported: '已匯出 {count} 個播放清單連結。',
toastNoPlaylist: '頁面上未找到播放清單連結。',
promptSetProxy: '輸入自訂 CORS 代理 URL 前綴(例如 https://myproxy.com/?url=)或留空以清除:',
toastProxyConfigured: '已設定自訂 CORS 代理。',
toastProxyCleared: '已清除自訂 CORS 代理。',
ariaMinimize: '最小化',
ariaClose: '關閉',
ariaSelectService: '選擇服務',
ariaFormatDownload: '下載格式 {quality}',
ariaCobaltRow: '使用 Cobalt 下載',
promptSetCobaltUrl: '輸入 Cobalt API 地址(例如 cobalt.tools):',
labelTiktok: 'TikTok 影片',
labelInstagram: 'Instagram 影片',
labelTwitter: 'Twitter 影片',
labelFacebook: 'Facebook 影片',
labelThreads: 'Threads 影片',
labelDouyin: '抖音影片',
labelXhs: '小紅書影片',
labelTed: 'TED 影片',
labelSnapchat: 'Snapchat 影片',
labelPinterest: 'Pinterest 影片',
labelSpotify: 'Spotify 音樂',
labelYoutube: 'YouTube 影片',
labelHlsBlob: 'HLS 流 (不支援直接下載)',
labelHtml5Video: 'HTML5 影片',
toastCobaltApiUrl: 'Cobalt API 地址: {url}'
},
vi: {
title: 'Trình tải video HD',
download: 'Tải xuống',
fallback: 'Dự phòng',
cobalt: 'Cobalt',
copyLink: 'Sao chép link',
exportPlaylist: 'Xuất Playlist',
subtitles: 'Phụ đề',
subtitleTrack: 'Bản Phụ đề',
noFormats: 'Không tìm thấy định dạng tải trực tiếp. Sử dụng công cụ dự phòng bên dưới.',
spotifyRedirect: 'Nhạc Spotify cần chuyển đổi bên ngoài. Đang chuyển đến spotifydown.com bên dưới.',
externalWarning: '⚠️ Các dịch vụ bên ngoài có thể chứa quảng cáo và không thuộc quyền kiểm soát của script này.',
toastCopySuccess: 'Đã sao chép link vào bộ nhớ tạm',
toastCopyFail: 'Không thể sao chép link',
toastYtBlocked: 'YouTube đang chặn tải trực tiếp. Sử dụng dự phòng.',
toastNoVideo: 'Không tìm thấy video nào trên trang này.',
toastSubtitlesSuccess: 'Tải phụ đề thành công',
toastSubtitlesFail: 'Tải phụ đề thất bại',
toastDownloadSuccess: 'Tải xuống thành công',
toastDirectFail: 'Tải trực tiếp thất bại. Nhấp vào "Dự phòng" bên dưới để sử dụng công cụ bên ngoài.',
toastDirectFailConfirm: 'Tải trực tiếp thất bại. Bạn có muốn chuyển hướng đến dịch vụ tải bên ngoài?',
toastHlsDetected: 'Đã phát hiện luồng HLS. Đang chuyển hướng sang dự phòng...',
toastYtCipherFail: 'Giải mã chữ ký YouTube thất bại. Đang chuyển hướng sang dự phòng...',
cancel: 'Hủy bỏ',
allCanceled: 'Đã hủy tất cả lượt tải xuống',
floatingButtonLabel: 'Mở tùy chọn tải video',
nativeYtDlTitle: 'Tải video (Ctrl+Alt+Shift+D)',
cobaltConfirmTitle: 'Trình tải Cobalt',
cobaltConfirmDesc: 'Bạn đã sao chép link chia sẻ của video này trên TikTok chưa?<br><br>Khuyên dùng link chia sẻ đã sao chép (Ctrl+C) để đạt kết quả tốt nhất trên Cobalt.',
cobaltConfirmYes: 'Có, dùng link Ctrl+C',
cobaltConfirmNo: 'Không, dùng link phát hiện được',
cobaltPromptNotTikTok: 'Link trong bộ nhớ tạm không giống link TikTok. Hãy dán link video tại đây:',
cobaltPromptFallback: 'Vui lòng dán link TikTok đã sao chép tại đây:',
toastSettingEnabled: 'đã bật',
toastSettingDisabled: 'đã tắt',
toastReloadPage: 'Tải lại trang.',
langSelectTitle: 'Chọn ngôn ngữ',
langSelectDesc: 'Vui lòng chọn ngôn ngữ ưu thích của bạn cho OmniDown:',
langSelectConfirm: 'Lưu',
currentLabel: 'Hiện tại',
deciphering: 'Đang giải mã...',
cipherBlocked: 'Chữ ký bị chặn',
adaptiveNoAudio: 'Tương thích (Không âm thanh)',
audioOnly: 'Chỉ âm thanh',
muxed: 'Hỗn hợp',
menuOpenMenu: '📥 Mở Bảng Tải Xuống',
menuDownloadCobalt: '⚡ Tải phương tiện (Cobalt)',
menuChangeLang: '🌐 Thay đổi ngôn ngữ / Alterar Idioma / Change Language',
menuSetCobaltUrl: '⚙️ Cài đặt URL API Cobalt',
menuSetProxy: '⚙️ Cài đặt proxy CORS',
menuToggleBtn: '🔘 Bật/Tắt Nút Nổi UI',
menuToggleFallback: '🔘 Bật/Tắt Tự Động Dự Phòng',
menuToggleHotkey: '🔘 Bật/Tắt Phím Tắt',
toastPlaylistExported: 'Đã xuất {count} link playlist.',
toastNoPlaylist: 'Không tìm thấy link playlist nào trên trang.',
promptSetProxy: 'Nhập tiền tố URL proxy CORS tùy chỉnh (ví dụ: https://myproxy.com/?url=) hoặc để trống để xóa:',
toastProxyConfigured: 'Đã cấu hình proxy CORS tùy chỉnh.',
toastProxyCleared: 'Đã xóa proxy CORS tùy chỉnh.',
ariaMinimize: 'Thu nhỏ',
ariaClose: 'Đóng',
ariaSelectService: 'Chọn dịch vụ',
ariaFormatDownload: 'Tải định dạng {quality}',
ariaCobaltRow: 'Tải xuống bằng Cobalt',
promptSetCobaltUrl: 'Nhập URL API Cobalt (ví dụ: cobalt.tools):',
labelTiktok: 'Video TikTok',
labelInstagram: 'Video Instagram',
labelTwitter: 'Video Twitter',
labelFacebook: 'Video Facebook',
labelThreads: 'Video Threads',
labelDouyin: 'Video Douyin',
labelXhs: 'Video XiaoHongShu',
labelTed: 'Video TED',
labelSnapchat: 'Video Snapchat',
labelPinterest: 'Video Pinterest',
labelSpotify: 'Nhạc Spotify',
labelYoutube: 'Video YouTube',
labelHlsBlob: 'Luồng HLS (Không hỗ trợ tải trực tiếp)',
labelHtml5Video: 'Video HTML5',
toastCobaltApiUrl: 'URL API Cobalt: {url}'
},
es: {
title: 'Descargador de Video HD',
download: 'Descargar',
fallback: 'Fallback',
cobalt: 'Cobalt',
copyLink: 'Copiar Enlace',
exportPlaylist: 'Exportar Lista',
subtitles: 'Subtítulos',
subtitleTrack: 'Pista de Subtítulos',
noFormats: 'No se encontraron formatos de descarga directa. Use las herramientas alternativas abajo.',
spotifyRedirect: 'Las canciones de Spotify requieren conversión externa. Redirigiendo a spotifydown.com abajo.',
externalWarning: '⚠️ Los servicios externos pueden contener anuncios y no están controlados por este script.',
toastCopySuccess: 'Enlace copiado al portapapeles',
toastCopyFail: 'Error al copiar el enlace',
toastYtBlocked: 'YouTube está bloqueando las descargas directas. Utilice fallback.',
toastNoVideo: 'No se encontró ningún video en esta página.',
toastSubtitlesSuccess: 'Subtítulos descargados con éxito',
toastSubtitlesFail: 'Error al descargar subtítulos',
toastDownloadSuccess: 'Descarga completada con éxito',
toastDirectFail: 'Descarga directa fallida. Haga clic en "Fallback" abajo para usar una herramienta externa.',
toastDirectFailConfirm: 'La descarga directa falló. ¿Redirigir al servicio de descarga externo?',
toastHlsDetected: 'Transmisión HLS detectada. Redirigiendo a fallback...',
toastYtCipherFail: 'Error al descifrar la firma de YouTube. Redirigiendo a fallback...',
cancel: 'Cancelar',
allCanceled: 'Todas las descargas canceladas',
floatingButtonLabel: 'Abrir opciones de descarga de video',
nativeYtDlTitle: 'Descargar Video (Ctrl+Alt+Shift+D)',
cobaltConfirmTitle: 'Descargador Cobalt',
cobaltConfirmDesc: '¿Copió el enlace de compartir de este video en TikTok?<br><br>Recomendamos usar el enlace copiado (Ctrl+C) para obtener el mejor resultado en Cobalt.',
cobaltConfirmYes: 'Sí, usar enlace de Ctrl+C',
cobaltConfirmNo: 'No, usar enlace detectado',
cobaltPromptNotTikTok: 'El enlace en el portapapeles no parece ser de TikTok. Pegue el enlace del video aquí:',
cobaltPromptFallback: 'Por favor, pegue el enlace copiado de TikTok aquí:',
toastSettingEnabled: 'activado',
toastSettingDisabled: 'desactivado',
toastReloadPage: 'Recargue la página.',
langSelectTitle: 'Seleccione el idioma',
langSelectDesc: 'Por favor, seleccione su idioma de preferencia para OmniDown:',
langSelectConfirm: 'Guardar',
currentLabel: 'Actual',
deciphering: 'Descifrando...',
cipherBlocked: 'Firma Bloqueada',
adaptiveNoAudio: 'Adaptativo (Sin Audio)',
audioOnly: 'Audio Solamente',
muxed: 'Mixto',
menuOpenMenu: '📥 Abrir Menú de Descarga',
menuDownloadCobalt: '⚡ Descargar con Cobalt',
menuChangeLang: '🌐 Cambiar Idioma / Alterar Idioma / Change Language',
menuSetCobaltUrl: '⚙️ Establecer URL de API Cobalt',
menuSetProxy: '⚙️ Establecer Proxy CORS',
menuToggleBtn: '🔘 Alternar Botón Flotante',
menuToggleFallback: '🔘 Alternar Auto-Fallback',
menuToggleHotkey: '🔘 Alternar Atajo de Teclado',
toastPlaylistExported: 'Exportado {count} enlaces de la lista.',
toastNoPlaylist: 'No se encontraron enlaces de listas en la página.',
promptSetProxy: 'Ingrese el prefijo de URL del proxy CORS personalizado (por ejemplo, https://myproxy.com/?url=) o déjelo en blanco para borrar:',
toastProxyConfigured: 'Proxy CORS personalizado configurado.',
toastProxyCleared: 'Proxy CORS personalizado borrado.',
ariaMinimize: 'Minimizar',
ariaClose: 'Cerrar',
ariaSelectService: 'Seleccionar Servicio',
ariaFormatDownload: 'Descargar formato {quality}',
ariaCobaltRow: 'Descargar con Cobalt',
promptSetCobaltUrl: 'Ingrese la URL de la API Cobalt (por ejemplo, cobalt.tools):',
labelTiktok: 'Video de TikTok',
labelInstagram: 'Video de Instagram',
labelTwitter: 'Video de Twitter',
labelFacebook: 'Video de Facebook',
labelThreads: 'Video de Threads',
labelDouyin: 'Video de Douyin',
labelXhs: 'Video de XiaoHongShu',
labelTed: 'Video de TED',
labelSnapchat: 'Video de Snapchat',
labelPinterest: 'Video de Pinterest',
labelSpotify: 'Música de Spotify',
labelYoutube: 'Video de YouTube',
labelHlsBlob: 'Transmisión HLS (Descarga directa no soportada)',
labelHtml5Video: 'Video HTML5',
toastCobaltApiUrl: 'URL de la API Cobalt: {url}'
}
};
// Merging TikTok languages after TRANSLATIONS is fully defined
const tiktokLangs = {
en: {
copyTikTokLink: 'Copy TikTok Link',
tiktokNoVideo: 'No TikTok video detected',
tiktokLinkCopied: 'TikTok link copied!',
tiktokOpenLink: 'Open Link',
tiktokNoWatermark: 'No Watermark',
tiktokDetected: 'Detected Video',
tiktokWaiting: '🔴 Waiting...',
waiting: 'Waiting...',
ready: 'Ready!',
idOnly: 'ID only',
copy: 'Copy',
copyLink: 'Copy link',
cobaltFast: 'Cobalt Fast Download',
refresh: 'Refresh',
linkCopied: 'Link copied!',
copyError: 'Error copying',
incompleteData: 'Incomplete data',
openingMenu: 'Opening menu...',
cobaltStarted: 'Cobalt download started!',
refreshing: 'Refreshing...',
downloadStarted: 'Download started!',
cobaltError: 'Cobalt error'
},
pt: {
copyTikTokLink: 'Copiar Link do TikTok',
tiktokNoVideo: 'Nenhum vídeo do TikTok detectado',
tiktokLinkCopied: 'Link do TikTok copiado!',
tiktokOpenLink: 'Abrir Link',
tiktokNoWatermark: 'Sem Marca d\'Água',
tiktokDetected: 'Vídeo Detectado',
tiktokWaiting: '🔴 Esperando...',
waiting: 'Esperando...',
ready: 'Pronto!',
idOnly: 'Só ID',
copy: 'Copiar',
copyLink: 'Copiar link',
cobaltFast: 'Cobalt Fast Download',
refresh: 'Atualizar',
linkCopied: 'Link copiado!',
copyError: 'Erro ao copiar',
incompleteData: 'Dados incompletos',
openingMenu: 'Abrindo menu...',
cobaltStarted: 'Download Cobalt iniciado!',
refreshing: 'Atualizando...',
downloadStarted: 'Download iniciado!',
cobaltError: 'Erro no Cobalt'
},
'zh-CN': {
copyTikTokLink: '复制 TikTok 链接',
tiktokNoVideo: '未检测到 TikTok 视频',
tiktokLinkCopied: 'TikTok 链接已复制!',
tiktokOpenLink: '打开链接',
tiktokNoWatermark: '无水印',
tiktokDetected: '已检测到视频',
tiktokWaiting: '🔴 等待中...',
waiting: '等待中...',
ready: '就绪!',
idOnly: '仅ID',
copy: '复制',
copyLink: '复制链接',
cobaltFast: 'Cobalt 快速下载',
refresh: '刷新',
linkCopied: '链接已复制!',
copyError: '复制错误',
incompleteData: '数据不完整',
openingMenu: '打开菜单...',
cobaltStarted: 'Cobalt 下载已开始!',
refreshing: '刷新中...',
downloadStarted: '下载已开始!',
cobaltError: 'Cobalt 错误'
},
'zh-TW': {
copyTikTokLink: '複製 TikTok 連結',
tiktokNoVideo: '未檢測到 TikTok 影片',
tiktokLinkCopied: 'TikTok 連結已複製!',
tiktokOpenLink: '打開連結',
tiktokNoWatermark: '無水印',
tiktokDetected: '已檢測到影片',
tiktokWaiting: '🔴 等待中...',
waiting: '等待中...',
ready: '就緒!',
idOnly: '僅ID',
copy: '複製',
copyLink: '複製連結',
cobaltFast: 'Cobalt 快速下載',
refresh: '刷新',
linkCopied: '連結已複製!',
copyError: '複製錯誤',
incompleteData: '資料不完整',
openingMenu: '開啟選單...',
cobaltStarted: 'Cobalt 下載已開始!',
refreshing: '刷新中...',
downloadStarted: '下載已開始!',
cobaltError: 'Cobalt 錯誤'
},
vi: {
copyTikTokLink: 'Sao chép Link TikTok',
tiktokNoVideo: 'Không phát hiện video TikTok',
tiktokLinkCopied: 'Đã sao chép link TikTok!',
tiktokOpenLink: 'Mở Link',
tiktokNoWatermark: 'Không có dấu nước',
tiktokDetected: 'Đã phát hiện video',
tiktokWaiting: '🔴 Đang đợi...',
waiting: 'Đang chờ...',
ready: 'Sẵn sàng!',
idOnly: 'Chỉ ID',
copy: 'Sao chép',
copyLink: 'Sao chép link',
cobaltFast: 'Tải nhanh Cobalt',
refresh: 'Làm mới',
linkCopied: 'Đã sao chép link!',
copyError: 'Lỗi sao chép',
incompleteData: 'Dữ liệu không đầy đủ',
openingMenu: 'Đang mở menu...',
cobaltStarted: 'Đã bắt đầu tải Cobalt!',
refreshing: 'Đang làm mới...',
downloadStarted: 'Đã bắt đầu tải!',
cobaltError: 'Lỗi Cobalt'
},
es: {
copyTikTokLink: 'Copiar Enlace de TikTok',
tiktokNoVideo: 'No se detectó video de TikTok',
tiktokLinkCopied: '¡Enlace de TikTok copiado!',
tiktokOpenLink: 'Abrir Enlace',
tiktokNoWatermark: 'Sin marca de agua',
tiktokDetected: 'Video Detectado',
tiktokWaiting: '🔴 Esperando...',
waiting: 'Esperando...',
ready: '¡Listo!',
idOnly: 'Solo ID',
copy: 'Copiar',
copyLink: 'Copiar link',
cobaltFast: 'Descarga rápida Cobalt',
refresh: 'Actualizar',
linkCopied: '¡Link copiado!',
copyError: 'Error al copiar',
incompleteData: 'Datos incompletos',
openingMenu: 'Abriendo menú...',
cobaltStarted: '¡Descarga Cobalt iniciada!',
refreshing: 'Actualizando...',
downloadStarted: '¡Descarga iniciada!',
cobaltError: 'Error de Cobalt'
}
};
for (const lang of Object.keys(tiktokLangs)) {
if (TRANSLATIONS[lang]) {
Object.assign(TRANSLATIONS[lang], tiktokLangs[lang]);
}
}
const uiModeLangs = {
en: {
uiModeSidebar: 'UI Mode: Sidebar',
uiModePopup: 'UI Mode: Popup',
menuToggleUI: '🎨 Toggle UI Mode'
},
pt: {
uiModeSidebar: 'Modo UI: Sidebar',
uiModePopup: 'Modo UI: Popup',
menuToggleUI: '🎨 Alternar Modo UI'
},
'zh-CN': {
uiModeSidebar: 'UI模式:侧边栏',
uiModePopup: 'UI模式:弹窗',
menuToggleUI: '🎨 切换UI模式'
},
'zh-TW': {
uiModeSidebar: 'UI模式:側邊欄',
uiModePopup: 'UI模式:彈窗',
menuToggleUI: '🎨 切換UI模式'
},
vi: {
uiModeSidebar: 'Chế độ UI: Thanh bên',
uiModePopup: 'Chế độ UI: Cửa sổ',
menuToggleUI: '🎨 Chuyển đổi chế độ UI'
},
es: {
uiModeSidebar: 'Modo UI: Sidebar',
uiModePopup: 'Modo UI: Popup',
menuToggleUI: '🎨 Alternar Modo UI'
}
};
for (const lang of Object.keys(uiModeLangs)) {
if (TRANSLATIONS[lang]) {
Object.assign(TRANSLATIONS[lang], uiModeLangs[lang]);
}
}
const dashboardLangs = {
en: {
settingsSaved: 'Settings saved!',
settingsReset: 'Settings reset to defaults',
dashboardTitle: 'OmniDown Dashboard',
appearance: 'Appearance',
general: 'General',
advanced: 'Advanced',
siteSpecific: 'Site-Specific',
about: 'About',
darkMode: 'Dark Mode',
lightMode: 'Light Mode',
autoTheme: 'Auto (System)',
accentColor: 'Accent Color',
glassIntensity: 'Glass Intensity',
borderRadius: 'Border Radius',
fontSize: 'Font Size',
saveChanges: '💾 Save Changes',
resetDefaults: '🔄 Reset to Defaults',
menuDashboard: '⚙️ Open Dashboard',
firstRunToast: '⚙️ OmniDown: Press Ctrl + Alt + Shift + D to configure settings'
},
pt: {
settingsSaved: 'Configurações salvas!',
settingsReset: 'Configurações restauradas',
dashboardTitle: 'Painel OmniDown',
appearance: 'Aparência',
general: 'Geral',
advanced: 'Avançado',
siteSpecific: 'Por Site',
about: 'Sobre',
darkMode: 'Modo Escuro',
lightMode: 'Modo Claro',
autoTheme: 'Auto (Sistema)',
accentColor: 'Cor de Destaque',
glassIntensity: 'Intensidade do Vidro',
borderRadius: 'Arredondamento',
fontSize: 'Tamanho da Fonte',
saveChanges: '💾 Salvar Alterações',
resetDefaults: '🔄 Restaurar Padrões',
menuDashboard: '⚙️ Abrir Painel',
firstRunToast: '⚙️ OmniDown: Pressione Ctrl + Alt + Shift + D para abrir as configurações'
},
'zh-CN': {
settingsSaved: '设置已保存!',
settingsReset: '设置已重置',
dashboardTitle: 'OmniDown 控制台',
appearance: '外观',
general: '常规',
advanced: '高级',
siteSpecific: '站点设置',
about: '关于',
darkMode: '深色模式',
lightMode: '浅色模式',
autoTheme: '自动(跟随系统)',
accentColor: '强调色',
glassIntensity: '玻璃强度',
borderRadius: '圆角',
fontSize: '字体大小',
saveChanges: '💾 保存更改',
resetDefaults: '🔄 重置默认',
menuDashboard: '⚙️ 打开控制台',
firstRunToast: '⚙️ OmniDown: 按 Ctrl + Alt + Shift + D 打开设置'
},
'zh-TW': {
settingsSaved: '設定已儲存!',
settingsReset: '設定已重置',
dashboardTitle: 'OmniDown 控制台',
appearance: '外觀',
general: '一般',
advanced: '進階',
siteSpecific: '站點設定',
about: '關於',
darkMode: '深色模式',
lightMode: '淺色模式',
autoTheme: '自動(跟隨系統)',
accentColor: '強調色',
glassIntensity: '玻璃強度',
borderRadius: '圓角',
fontSize: '字體大小',
saveChanges: '💾 儲存變更',
resetDefaults: '🔄 重置預設',
menuDashboard: '⚙️ 開啟控制台',
firstRunToast: '⚙️ OmniDown: 按 Ctrl + Alt + Shift + D 開啟設定'
},
vi: {
settingsSaved: 'Đã lưu cài đặt!',
settingsReset: 'Đã đặt lại cài đặt',
dashboardTitle: 'Bảng điều khiển OmniDown',
appearance: 'Giao diện',
general: 'Chung',
advanced: 'Nâng cao',
siteSpecific: 'Theo Trang',
about: 'Giới thiệu',
darkMode: 'Chế độ tối',
lightMode: 'Chế độ sáng',
autoTheme: 'Tự động (Hệ thống)',
accentColor: 'Màu nhấn',
glassIntensity: 'Độ trong suốt',
borderRadius: 'Bo góc',
fontSize: 'Cỡ chữ',
saveChanges: '💾 Lưu thay đổi',
resetDefaults: '🔄 Đặt lại mặc định',
menuDashboard: '⚙️ Mở bảng điều khiển',
firstRunToast: '⚙️ OmniDown: Nhấn Ctrl + Alt + Shift + D để mở cài đặt'
},
es: {
settingsSaved: '¡Configuración guardada!',
settingsReset: 'Configuración restablecida',
dashboardTitle: 'Panel de OmniDown',
appearance: 'Apariencia',
general: 'General',
advanced: 'Avanzado',
siteSpecific: 'Por Sitio',
about: 'Acerca de',
darkMode: 'Modo Oscuro',
lightMode: 'Modo Claro',
autoTheme: 'Auto (Sistema)',
accentColor: 'Color de Acento',
glassIntensity: 'Intensidad del Cristal',
borderRadius: 'Radio de Borde',
fontSize: 'Tamaño de Fuente',
saveChanges: '💾 Guardar Cambios',
resetDefaults: '🔄 Restablecer',
menuDashboard: '⚙️ Abrir Panel',
firstRunToast: '⚙️ OmniDown: Presiona Ctrl + Alt + Shift + D para abrir la configuración'
}
};
for (const lang of Object.keys(dashboardLangs)) {
if (TRANSLATIONS[lang]) {
Object.assign(TRANSLATIONS[lang], dashboardLangs[lang]);
}
}
const getBrowserLang = () => {
const l = navigator.language || navigator.userLanguage || 'en';
if (l.startsWith('pt')) return 'pt';
if (l.startsWith('zh-CN') || l.startsWith('zh-cn')) return 'zh-CN';
if (l.startsWith('zh-TW') || l.startsWith('zh-tw') || l.startsWith('zh-HK') || l.startsWith('zh-hk')) return 'zh-TW';
if (l.startsWith('zh')) return 'zh-CN';
if (l.startsWith('vi')) return 'vi';
if (l.startsWith('es')) return 'es';
return 'en';
};
const getSelectedLang = () => {
return getSetting(SETTINGS_KEYS.language, null);
};
const getText = (key) => {
const lang = getSelectedLang() || getBrowserLang();
const dict = TRANSLATIONS[lang] || TRANSLATIONS['en'];
return dict[key] || TRANSLATIONS['en'][key] || key;
};
const showLanguageSelectionModal = (onComplete) => {
const backdrop = document.createElement('div');
backdrop.className = 'ksmo-modal-backdrop';
backdrop.style.zIndex = '10000003';
const modal = document.createElement('div');
modal.className = 'ksmo-modal';
modal.style.maxWidth = '360px';
modal.style.padding = '20px';
const title = document.createElement('div');
title.className = 'ksmo-modal-title';
title.style.marginBottom = '12px';
title.textContent = getText('langSelectTitle');
const desc = document.createElement('div');
desc.style.fontSize = '13px';
desc.style.color = '#e2e8f0';
desc.style.lineHeight = '1.6';
desc.style.marginBottom = '20px';
desc.textContent = getText('langSelectDesc');
const select = document.createElement('select');
select.className = 'ksmo-select';
select.style.width = '100%';
select.style.marginBottom = '20px';
const langs = [
{ code: 'en', name: 'English' },
{ code: 'pt', name: 'Português' },
{ code: 'zh-CN', name: '简体中文 (Simplified Chinese)' },
{ code: 'zh-TW', name: '繁體中文 (Traditional Chinese)' },
{ code: 'vi', name: 'Tiếng Việt' },
{ code: 'es', name: 'Español' }
];
const defaultLang = getSelectedLang() || getBrowserLang();
langs.forEach(l => {
const opt = document.createElement('option');
opt.value = l.code;
opt.textContent = l.name;
if (l.code === defaultLang) opt.selected = true;
select.appendChild(opt);
});
const saveBtn = document.createElement('button');
saveBtn.className = 'ksmo-dl-btn';
saveBtn.textContent = getText('langSelectConfirm');
saveBtn.style.background = 'linear-gradient(135deg, #6c5ce7, #4b3fd4)';
modal.appendChild(title);
modal.appendChild(desc);
modal.appendChild(select);
modal.appendChild(saveBtn);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
setTimeout(() => backdrop.classList.add('show'), 10);
saveBtn.addEventListener('click', () => {
setSetting(SETTINGS_KEYS.language, select.value);
backdrop.classList.remove('show');
setTimeout(() => {
backdrop.remove();
if (onComplete) onComplete();
}, 300);
});
};
const getCobaltApiUrl = () => {
return getSetting(SETTINGS_KEYS.cobaltApiUrl, 'cobalt.tools');
};
const openCobaltUI = (targetUrl) => {
let apiUrl = getCobaltApiUrl();
if (!apiUrl.startsWith("https://") && !apiUrl.startsWith("http://")) apiUrl = `https://` + apiUrl;
if (!apiUrl.endsWith("/")) apiUrl = apiUrl + '/';
apiUrl = apiUrl + `#${encodeURIComponent(targetUrl)}`;
if (typeof GM_openInTab !== 'undefined') {
GM_openInTab(apiUrl, { active: true });
} else {
window.open(apiUrl, '_blank');
}
};
/**
* Shows a beautiful modal dialog asking the user to confirm whether they've
* copied the sharing link on TikTok. This helps Cobalt fetch watermark-free
* media using the correct user-copied link rather than a CDN URL.
*/
const showTikTokCobaltConfirmation = (detectedUrl) => {
const backdrop = document.createElement('div');
backdrop.className = 'ksmo-modal-backdrop';
backdrop.style.zIndex = '10000002'; // Render above main modal
const modal = document.createElement('div');
modal.className = 'ksmo-modal';
modal.style.maxWidth = '360px';
modal.style.padding = '20px';
const title = document.createElement('div');
title.className = 'ksmo-modal-title';
title.style.marginBottom = '12px';
title.textContent = getText('cobaltConfirmTitle');
const desc = document.createElement('div');
desc.style.fontSize = '13px';
desc.style.color = '#e2e8f0';
desc.style.lineHeight = '1.6';
desc.style.marginBottom = '20px';
setSafeHTMLWithBr(desc, getText('cobaltConfirmDesc'));
const btnContainer = document.createElement('div');
btnContainer.style.display = 'flex';
btnContainer.style.flexDirection = 'column';
btnContainer.style.gap = '8px';
const useClipboardBtn = document.createElement('button');
useClipboardBtn.className = 'ksmo-dl-btn';
useClipboardBtn.textContent = getText('cobaltConfirmYes');
useClipboardBtn.style.background = 'linear-gradient(135deg, #6c5ce7, #4b3fd4)';
const useDetectedBtn = document.createElement('button');
useDetectedBtn.className = 'ksmo-secondary-btn';
useDetectedBtn.textContent = getText('cobaltConfirmNo');
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ksmo-secondary-btn';
cancelBtn.textContent = getText('cancel');
cancelBtn.style.color = '#ef4444';
cancelBtn.style.borderColor = 'rgba(239, 68, 68, 0.2)';
btnContainer.appendChild(useClipboardBtn);
btnContainer.appendChild(useDetectedBtn);
btnContainer.appendChild(cancelBtn);
modal.appendChild(title);
modal.appendChild(desc);
modal.appendChild(btnContainer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
setTimeout(() => backdrop.classList.add('show'), 10);
const dismiss = () => {
backdrop.classList.remove('show');
setTimeout(() => backdrop.remove(), 300);
};
useClipboardBtn.addEventListener('click', () => {
dismiss();
navigator.clipboard.readText().then(clipText => {
const trimmed = clipText ? clipText.trim() : '';
if (trimmed && (trimmed.includes('tiktok.com') || trimmed.includes('vt.tiktok.com') || trimmed.includes('vm.tiktok.com'))) {
openCobaltUI(trimmed);
} else {
const pasted = prompt(getText('cobaltPromptNotTikTok'), trimmed);
if (pasted) openCobaltUI(pasted.trim());
}
}).catch(() => {
const pasted = prompt(getText('cobaltPromptFallback'));
if (pasted) openCobaltUI(pasted.trim());
});
});
useDetectedBtn.addEventListener('click', () => {
dismiss();
openCobaltUI(detectedUrl);
});
cancelBtn.addEventListener('click', () => {
dismiss();
});
};
const FALLBACK_SERVICES = {
ytdown: (url) => `https://app.ytdown.to/?url=${encodeURIComponent(url)}`,
snapscooper: (url) => `https://snapscooper.com/tools/yt0?url=${encodeURIComponent(url)}`,
y2meta: (url) => `https://y2meta.is/download/?url=${encodeURIComponent(url)}`,
savefrom: (url) => `https://en1.savefrom.net/1-youtube-video-downloader-20pH/?url=${encodeURIComponent(url)}`,
snaptik: (url) => `https://snaptik.app/?url=${encodeURIComponent(url)}`,
ttdownloader: (url) => `https://ttdownloader.tools/?url=${encodeURIComponent(url)}`,
savetik: (url) => `https://savetik.co/pt/douyin-downloader/?url=${encodeURIComponent(url)}`,
seekin: (url) => `https://www.seekin.ai/douyin-downloader/?url=${encodeURIComponent(url)}`,
fdownloader: (url) => `https://fdownloader.net/?url=${encodeURIComponent(url)}`,
boilerplatehub: (url) => `https://boilerplatehub.com/free-tools/twitter-video-downloader?url=${encodeURIComponent(url)}`,
twittervideodownloader: (url) => `https://twittervideodownloader.com/download?url=${encodeURIComponent(url)}`,
ssstwitter: (url) => `https://ssstwitter.com/?url=${encodeURIComponent(url)}`,
sssinstagram: (url) => `https://sssinstagram.com/?url=${encodeURIComponent(url)}`,
saveclip: (url) => `https://saveclip.app/?url=${encodeURIComponent(url)}`,
fastdl: (url) => `https://fastdl.app/?url=${encodeURIComponent(url)}`,
downreels: (url) => `https://downreels.com/instagram-video-downloader-free/?url=${encodeURIComponent(url)}`,
storygrab: (url) => `https://storygrab.io/en1?url=${encodeURIComponent(url)}`,
schatdownloader: (url) => `https://schatdownloader.com/?url=${encodeURIComponent(url)}`,
pindown: (url) => `https://pindown.io/pt1?url=${encodeURIComponent(url)}`,
pinpea: (url) => `https://www.pinpea.com/pt/?url=${encodeURIComponent(url)}`,
spotifydown: (url) => `https://spotifydown.com/?link=${encodeURIComponent(url)}`,
tool77: (url) => `https://www.tool77.com/en/v/downloader?url=${encodeURIComponent(url)}`
};
const SITE_FALLBACKS = {
'youtube.com': ['ytdown', 'snapscooper', 'y2meta', 'savefrom'],
'tiktok.com': ['snaptik', 'ttdownloader'],
'douyin.com': ['savetik', 'seekin'],
'facebook.com': ['fdownloader'],
'threads.net': ['fdownloader'],
'twitter.com': ['boilerplatehub', 'twittervideodownloader', 'ssstwitter'],
'x.com': ['boilerplatehub', 'twittervideodownloader', 'ssstwitter'],
'instagram.com': ['sssinstagram', 'saveclip', 'fastdl', 'downreels'],
'snapchat.com': ['storygrab', 'schatdownloader'],
'pinterest.com': ['pindown', 'pinpea'],
'spotify.com': ['spotifydown']
};
const getAvailableFallbacks = () => {
const host = window.location.hostname;
for (const domain of Object.keys(SITE_FALLBACKS)) {
if (host.includes(domain)) {
return SITE_FALLBACKS[domain];
}
}
return ['tool77', 'savefrom', 'y2meta'];
};
const getFallbackServiceSettingKey = () => {
const host = window.location.hostname;
for (const domain of Object.keys(SITE_FALLBACKS)) {
if (host.includes(domain)) {
return `${SETTINGS_KEYS.fallbackService}_${domain}`;
}
}
return `${SETTINGS_KEYS.fallbackService}_generic`;
};
const getCorsProxies = () => {
const list = [];
const custom = getSetting(SETTINGS_KEYS.customProxy, '');
if (custom) {
list.push((url) => `${custom}${encodeURIComponent(url)}`);
}
list.push((url) => `https://corsproxy.io/?${encodeURIComponent(url)}`);
list.push((url) => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`);
return list;
};
const css = `
:root {
--od-bg-primary: #0a0a0f;
--od-bg-secondary: rgba(255, 255, 255, 0.04);
--od-bg-tertiary: rgba(255, 255, 255, 0.08);
--od-bg-hover: rgba(255, 255, 255, 0.12);
--od-text-primary: #ffffff;
--od-text-secondary: #94a3b8;
--od-text-tertiary: #64748b;
--od-accent: #6c5ce7;
--od-accent-hover: #a29bfe;
--od-accent-rgb: 108, 92, 231;
--od-border: rgba(255, 255, 255, 0.08);
--od-success: #00b894;
--od-danger: #ff7675;
--od-warning: #fdcb6e;
--od-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--od-glass: rgba(15, 15, 25, 0.85);
--od-glass-blur: 16px;
--od-glass-opacity: 0.85;
--od-radius: 16px;
--od-font-size: 14px;
--fab-size: 48px;
--fab-opacity: 1;
}
[data-od-theme="light"] {
--od-bg-primary: #f8f9fa;
--od-bg-secondary: rgba(0, 0, 0, 0.04);
--od-bg-tertiary: rgba(0, 0, 0, 0.08);
--od-bg-hover: rgba(0, 0, 0, 0.06);
--od-text-primary: #1a1a2e;
--od-text-secondary: #64748b;
--od-text-tertiary: #94a3b8;
--od-border: rgba(0, 0, 0, 0.08);
--od-glass: rgba(255, 255, 255, 0.9);
--od-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
/* 1. Global Floating Button Styling */
.ksmo-floating-dl {
position: fixed !important;
bottom: 96px !important;
right: 24px !important;
z-index: 2147483630 !important;
width: var(--fab-size, 48px) !important;
height: var(--fab-size, 48px) !important;
opacity: 0;
border-radius: 50% !important;
background: linear-gradient(135deg, var(--od-accent, #6c5ce7), var(--od-accent-hover, #4b3fd4)) !important;
border: 1px solid var(--od-border, rgba(255, 255, 255, 0.08)) !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6), 0 0 15px rgba(var(--od-accent-rgb, 108, 92, 231), 0.2) !important;
color: #f8fafc !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transform: scale(0.9) translateY(20px);
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
pointer-events: none !important;
}
.ksmo-floating-dl.visible {
opacity: var(--fab-opacity, 1) !important;
transform: scale(1) translateY(0) !important;
pointer-events: auto !important;
}
.ksmo-floating-dl.downloading {
background: linear-gradient(135deg, #ef4444, #b91c1c) !important;
animation: ksmo-pulse 1.5s infinite !important;
}
.ksmo-floating-dl:hover {
transform: scale(1.05) !important;
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.4) !important;
color: #ffffff !important;
}
.ksmo-floating-dl svg {
pointer-events: none !important;
}
/* 2. Embedded Video Hover Button Styling */
.ksmo-dl-trigger {
position: absolute !important;
top: 16px !important;
right: 16px !important;
z-index: 2147483640 !important;
width: 38px !important;
height: 38px !important;
border-radius: 50% !important;
background: rgba(10, 10, 18, 0.82) !important;
backdrop-filter: blur(16px) !important;
-webkit-backdrop-filter: blur(16px) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6), 0 0 15px rgba(108, 92, 231, 0.2) !important;
color: #f8fafc !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
opacity: 0;
transform: scale(0.9);
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
pointer-events: auto !important;
}
.ksmo-dl-trigger.visible {
opacity: 1 !important;
transform: scale(1) !important;
}
.ksmo-dl-trigger:hover {
background: linear-gradient(135deg, #6c5ce7, #4b3fd4) !important;
color: #ffffff !important;
border-color: rgba(255, 255, 255, 0.15) !important;
transform: scale(1.05) !important;
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.4) !important;
}
.ksmo-dl-trigger svg {
pointer-events: none !important;
}
/* 3. YouTube Control Bar Integration */
#ksmo-yt-control-btn svg {
stroke: #f8fafc;
opacity: 0.9;
transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1);
}
#ksmo-yt-control-btn:hover svg {
opacity: 1;
}
/* 4. Modal Overlay & Panel Styling */
.ksmo-modal-backdrop {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: rgba(10, 10, 15, 0.6) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
z-index: 2147483645 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
opacity: 0;
transition: opacity 0.3s ease !important;
pointer-events: auto !important;
}
.ksmo-modal-backdrop.show {
opacity: 1 !important;
}
.ksmo-modal-backdrop.minimized {
background: transparent !important;
pointer-events: none !important;
width: auto !important;
height: auto !important;
top: auto !important;
left: auto !important;
right: 0 !important;
bottom: 0 !important;
}
.ksmo-modal-backdrop.minimized .ksmo-modal {
position: fixed !important;
bottom: 24px !important;
right: 24px !important;
width: 300px !important;
padding: 14px !important;
border-radius: 12px !important;
transform: none !important;
box-shadow: 0 10px 30px rgba(0,0,0,0.5) !important;
pointer-events: none !important;
}
.ksmo-modal-backdrop.minimized .ksmo-modal-header,
.ksmo-modal-backdrop.minimized .ksmo-progress-container {
pointer-events: auto !important;
}
.ksmo-modal {
background: rgba(15, 15, 25, 0.85) !important;
backdrop-filter: blur(20px) !important;
-webkit-backdrop-filter: blur(20px) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 16px !important;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.6) !important;
width: 90% !important;
max-width: 440px !important;
padding: 24px !important;
color: #f8fafc !important;
font-family: system-ui, -apple-system, sans-serif !important;
transform: translateY(20px) scale(0.95) !important;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
pointer-events: auto !important;
}
.ksmo-modal-backdrop.show .ksmo-modal {
transform: translateY(0) scale(1) !important;
}
.ksmo-modal-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
margin-bottom: 20px !important;
}
.ksmo-modal-backdrop.minimized .ksmo-modal-header {
margin-bottom: 8px !important;
}
.ksmo-modal-title {
font-size: 16px !important;
font-weight: 600 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
max-width: 280px !important;
}
.ksmo-modal-backdrop.minimized .ksmo-modal-title {
max-width: 180px !important;
font-size: 13px !important;
}
.ksmo-modal-backdrop.minimized .ksmo-modal-title::after {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #6c5ce7;
margin-left: 8px;
box-shadow: 0 0 8px #6c5ce7;
animation: ksmo-pulse 1.5s infinite;
}
@keyframes ksmo-pulse {
0% { transform: scale(0.9); opacity: 0.6; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.6; }
}
.ksmo-header-actions {
display: flex !important;
gap: 6px !important;
align-items: center !important;
}
.ksmo-modal-close, .ksmo-modal-min {
background: transparent !important;
border: none !important;
color: #94a3b8 !important;
cursor: pointer !important;
font-size: 20px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
padding: 0 !important;
transition: color 0.2s ease !important;
}
.ksmo-modal-close:hover, .ksmo-modal-min:hover {
color: #f8fafc !important;
}
/* 5. Format Quality List Styles */
.ksmo-quality-list {
max-height: 240px !important;
overflow-y: auto !important;
margin-bottom: 20px !important;
padding-right: 4px !important;
}
.ksmo-quality-list::-webkit-scrollbar {
width: 6px !important;
}
.ksmo-quality-list::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02) !important;
border-radius: 3px !important;
}
.ksmo-quality-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15) !important;
border-radius: 3px !important;
}
.ksmo-quality-list::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25) !important;
}
.ksmo-modal-backdrop.minimized .ksmo-quality-list {
display: none !important;
}
.ksmo-quality-row {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 10px 12px !important;
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.04) !important;
border-radius: 8px !important;
margin-bottom: 8px !important;
transition: all 0.2s ease !important;
}
.ksmo-quality-row:hover {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.ksmo-quality-info {
display: flex !important;
flex-direction: column !important;
gap: 2px !important;
}
.ksmo-quality-name {
font-size: 14px !important;
font-weight: 500 !important;
}
.ksmo-quality-label {
font-size: 11px !important;
color: #94a3b8 !important;
}
.ksmo-dl-btn {
background: linear-gradient(135deg, #6c5ce7, #4b3fd4) !important;
color: #ffffff !important;
border: none !important;
border-radius: 6px !important;
padding: 6px 12px !important;
font-size: 13px !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}
.ksmo-dl-btn:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.4) !important;
}
/* 6. Footer Controls */
.ksmo-modal-footer {
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
}
.ksmo-modal-backdrop.minimized .ksmo-modal-footer {
display: none !important;
}
.ksmo-footer-row {
display: flex !important;
gap: 8px !important;
}
.ksmo-select {
flex: 1 !important;
background: rgba(10, 10, 15, 0.8) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #f8fafc !important;
border-radius: 8px !important;
padding: 8px 12px !important;
font-size: 13px !important;
outline: none !important;
cursor: pointer !important;
}
.ksmo-secondary-btn {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #f8fafc !important;
border-radius: 8px !important;
padding: 8px 16px !important;
font-size: 13px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.ksmo-secondary-btn:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
/* 7. Progress Bar Layouts */
.ksmo-progress-container {
display: none !important;
flex-direction: column !important;
gap: 6px !important;
margin-bottom: 20px !important;
}
.ksmo-modal-backdrop.minimized .ksmo-progress-container {
display: flex !important;
margin-bottom: 0 !important;
}
.ksmo-progress-bar {
width: 100% !important;
height: 6px !important;
background: rgba(255, 255, 255, 0.1) !important;
border-radius: 3px !important;
overflow: hidden !important;
}
.ksmo-progress-fill {
width: 0% !important;
height: 100% !important;
background: linear-gradient(90deg, #6c5ce7, #a29bfe) !important;
transition: width 0.1s linear !important;
}
.ksmo-progress-text {
display: flex !important;
justify-content: space-between !important;
font-size: 12px !important;
color: #94a3b8 !important;
}
.ksmo-modal-backdrop.minimized .ksmo-progress-cancel {
font-size: 0 !important;
}
.ksmo-modal-backdrop.minimized .ksmo-progress-cancel::after {
content: '✕' !important;
font-size: 14px !important;
font-weight: bold !important;
color: #ef4444 !important;
}
.ksmo-warning-label {
font-size: 11px !important;
color: #f59e0b !important;
text-align: center !important;
margin-top: 4px !important;
}
.ksmo-modal-backdrop.minimized .ksmo-warning-label {
display: none !important;
}
/* 8. Toast Alert Overlay */
.ksmo-toast {
position: fixed !important;
bottom: 24px !important;
left: 50% !important;
transform: translateX(-50%) translateY(20px) !important;
background: rgba(10, 10, 18, 0.9) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
color: #ffffff !important;
padding: 10px 20px !important;
border-radius: 10px !important;
font-size: 13px !important;
font-family: system-ui, -apple-system, sans-serif !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 10px 25px rgba(0,0,0,0.5) !important;
z-index: 2147483647 !important;
pointer-events: none !important;
opacity: 0;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.ksmo-toast.show {
opacity: 1 !important;
transform: translateX(-50%) translateY(0) !important;
}
/* 9. Responsiveness Adjustments */
@media (max-width: 400px) {
.ksmo-modal {
padding: 16px !important;
}
.ksmo-footer-row {
flex-direction: column !important;
}
}
.ksmo-modal-backdrop.ksmo-modal-tiktok {
z-index: 99998 !important;
background: transparent !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
pointer-events: none !important;
}
.ksmo-modal-backdrop.ksmo-modal-tiktok .ksmo-modal {
position: fixed !important;
left: auto !important;
width: 320px !important;
max-height: 85vh !important;
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
pointer-events: auto !important;
background: rgba(22, 24, 35, 0.95) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border-radius: 16px 0 0 16px !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-right: none !important;
box-shadow: -8px 8px 32px rgba(0, 0, 0, 0.3) !important;
z-index: 99998 !important;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease !important;
transform: translateX(80px) !important;
opacity: 0 !important;
}
.ksmo-modal-backdrop.ksmo-modal-tiktok.show .ksmo-modal {
transform: translateX(0) !important;
opacity: 1 !important;
}
.ksmo-modal-backdrop.ksmo-modal-tiktok .ksmo-footer-row {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 8px !important;
width: 100% !important;
}
.ksmo-modal-backdrop.ksmo-modal-tiktok .ksmo-footer-row > * {
margin: 0 !important;
width: 100% !important;
box-sizing: border-box !important;
}
.ksmo-modal-backdrop.ksmo-modal-tiktok .ksmo-footer-row > button:first-child {
grid-column: span 2 !important;
}
.ksmo-modal-backdrop.ksmo-modal-tiktok .ksmo-footer-row button,
.ksmo-modal-backdrop.ksmo-modal-tiktok .ksmo-footer-row select {
padding: 8px 4px !important;
font-size: 11px !important;
white-space: normal !important;
text-align: center !important;
min-height: 38px !important;
}
/* 12. OmniDown Sidebar Mode Styles */
#omnidown-sidebar {
position: fixed !important;
right: 0 !important;
top: 0 !important;
width: 320px !important;
height: 100vh !important;
background: rgba(15, 15, 25, 0.92) !important;
backdrop-filter: blur(24px) saturate(1.8) !important;
-webkit-backdrop-filter: blur(24px) saturate(1.8) !important;
border-left: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: -10px 0 50px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.05) !important;
z-index: 2147483646 !important;
display: flex !important;
flex-direction: column !important;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif !important;
color: #fff !important;
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1) !important;
transform: translateX(100%);
overflow: hidden !important;
}
#omnidown-sidebar.sidebar-open {
transform: translateX(0) !important;
}
#omnidown-sidebar .sidebar-header {
padding: 16px 20px !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
background: rgba(255, 255, 255, 0.02) !important;
}
#omnidown-sidebar .sidebar-title {
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: -0.3px !important;
background: linear-gradient(135deg, #fff 0%, #a29bfe 100%) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
}
#omnidown-sidebar .sidebar-close {
width: 28px !important;
height: 28px !important;
border-radius: 8px !important;
border: none !important;
background: rgba(255, 255, 255, 0.06) !important;
color: #94a3b8 !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all 0.2s !important;
}
#omnidown-sidebar .sidebar-close:hover {
background: rgba(255, 255, 255, 0.12) !important;
color: #fff !important;
transform: scale(1.05) !important;
}
#omnidown-sidebar .sidebar-content {
flex: 1 !important;
overflow-y: auto !important;
padding: 16px !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(108, 92, 231, 0.3) transparent !important;
}
#omnidown-sidebar .sidebar-section {
margin-bottom: 20px !important;
animation: sidebarFadeIn 0.4s ease forwards !important;
opacity: 0;
transform: translateY(10px);
}
#omnidown-sidebar .sidebar-section:nth-child(1) { animation-delay: 0.05s !important; }
#omnidown-sidebar .sidebar-section:nth-child(2) { animation-delay: 0.1s !important; }
#omnidown-sidebar .sidebar-section:nth-child(3) { animation-delay: 0.15s !important; }
#omnidown-sidebar .sidebar-section:nth-child(4) { animation-delay: 0.2s !important; }
@keyframes sidebarFadeIn {
to { opacity: 1; transform: translateY(0); }
}
#omnidown-sidebar .section-title {
font-size: 11px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 1px !important;
color: #64748b !important;
margin-bottom: 12px !important;
display: flex !important;
align-items: center !important;
gap: 6px !important;
}
#omnidown-sidebar .video-card {
background: rgba(255, 255, 255, 0.04) !important;
border-radius: 12px !important;
padding: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
transition: all 0.2s !important;
}
#omnidown-sidebar .video-card:hover {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(108, 92, 231, 0.2) !important;
}
#omnidown-sidebar .video-thumb {
width: 100% !important;
aspect-ratio: 16/9 !important;
border-radius: 8px !important;
object-fit: cover !important;
margin-bottom: 10px !important;
background: rgba(0, 0, 0, 0.3) !important;
}
#omnidown-sidebar .video-title {
font-size: 13px !important;
font-weight: 500 !important;
line-height: 1.4 !important;
color: #fff !important;
margin-bottom: 6px !important;
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
}
#omnidown-sidebar .video-meta {
font-size: 11px !important;
color: #94a3b8 !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
#omnidown-sidebar .video-meta a {
color: #6c5ce7 !important;
text-decoration: none !important;
font-weight: 500 !important;
}
#omnidown-sidebar .video-meta a:hover {
text-decoration: underline !important;
}
#omnidown-sidebar .download-item {
background: rgba(255, 255, 255, 0.04) !important;
border-radius: 10px !important;
padding: 12px 14px !important;
margin-bottom: 8px !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 12px !important;
transition: all 0.2s !important;
}
#omnidown-sidebar .download-item:hover {
background: rgba(108, 92, 231, 0.1) !important;
border-color: rgba(108, 92, 231, 0.3) !important;
transform: translateX(4px) !important;
}
#omnidown-sidebar .download-icon {
width: 36px !important;
height: 36px !important;
border-radius: 10px !important;
background: rgba(108, 92, 231, 0.15) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 16px !important;
flex-shrink: 0 !important;
}
#omnidown-sidebar .download-info {
flex: 1 !important;
min-width: 0 !important;
}
#omnidown-sidebar .download-label {
font-size: 12px !important;
font-weight: 500 !important;
color: #fff !important;
margin-bottom: 2px !important;
}
#omnidown-sidebar .download-detail {
font-size: 11px !important;
color: #64748b !important;
}
#omnidown-sidebar .action-grid {
display: grid !important;
grid-template-columns: repeat(4, 1fr) !important;
gap: 8px !important;
}
#omnidown-sidebar .action-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 10px !important;
padding: 10px 4px !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 4px !important;
cursor: pointer !important;
color: #94a3b8 !important;
font-size: 10px !important;
transition: all 0.2s !important;
}
#omnidown-sidebar .action-btn:hover {
background: rgba(108, 92, 231, 0.1) !important;
border-color: rgba(108, 92, 231, 0.3) !important;
color: #fff !important;
transform: translateY(-2px) !important;
}
#omnidown-sidebar .action-btn .action-icon {
font-size: 18px !important;
line-height: 1 !important;
}
#omnidown-sidebar .fallback-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 8px !important;
padding: 8px 12px !important;
font-size: 11px !important;
color: #94a3b8 !important;
cursor: pointer !important;
transition: all 0.2s !important;
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
margin-right: 6px !important;
margin-bottom: 6px !important;
}
#omnidown-sidebar .fallback-btn:hover {
background: rgba(255, 255, 255, 0.08) !important;
color: #fff !important;
}
.sidebar-scroll::-webkit-scrollbar {
width: 4px !important;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent !important;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: rgba(108, 92, 231, 0.3) !important;
border-radius: 2px !important;
}
.sidebar-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(108, 92, 231, 0.6) !important;
}
#omnidown-sidebar-overlay {
position: fixed !important;
inset: 0 !important;
background: rgba(0, 0, 0, 0.3) !important;
z-index: 2147483645 !important;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s !important;
}
#omnidown-sidebar-overlay.active {
opacity: 1 !important;
pointer-events: auto !important;
}
@media (max-width: 480px) {
#omnidown-sidebar {
width: 85vw !important;
border-radius: 16px 0 0 16px !important;
}
}
/* 13. OmniDown Dashboard Styles */
#omnidown-dashboard {
position: fixed !important;
inset: 0 !important;
z-index: 2147483647 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(16px) !important;
-webkit-backdrop-filter: blur(16px) !important;
opacity: 0;
transition: opacity 0.3s ease !important;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif !important;
animation: dashboardIn 0.3s forwards !important;
}
@keyframes dashboardIn {
to { opacity: 1; }
}
.od-panel {
width: 90vw !important;
max-width: 900px !important;
height: 85vh !important;
max-height: 700px !important;
background: var(--od-glass, rgba(15, 15, 25, 0.92)) !important;
border: 1px solid var(--od-border, rgba(255, 255, 255, 0.08)) !important;
border-radius: var(--od-radius, 24px) !important;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
transform: scale(0.95) translateY(20px);
animation: dashboardPanelIn 0.4s cubic-bezier(0.32, 0.72, 0, 1) forwards !important;
font-size: var(--od-font-size, 14px) !important;
}
@keyframes dashboardPanelIn {
to { transform: scale(1) translateY(0); }
}
.od-header {
padding: 16px 24px !important;
border-bottom: 1px solid var(--od-border) !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
background: rgba(255, 255, 255, 0.02) !important;
}
.od-header-title {
font-size: 18px !important;
font-weight: 700 !important;
background: linear-gradient(135deg, var(--od-accent, #6c5ce7) 0%, var(--od-accent-hover, #a29bfe) 100%) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
display: flex !important;
align-items: center !important;
gap: 10px !important;
}
.od-header-close {
width: 32px !important;
height: 32px !important;
border-radius: 10px !important;
border: none !important;
background: rgba(255, 255, 255, 0.06) !important;
color: var(--od-text-secondary, #94a3b8) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 18px !important;
transition: all 0.2s !important;
}
.od-header-close:hover {
background: rgba(255, 255, 255, 0.12) !important;
color: var(--od-text-primary, #fff) !important;
transform: scale(1.05) !important;
}
.od-body {
flex: 1 !important;
display: flex !important;
overflow: hidden !important;
}
.od-nav {
width: 220px !important;
border-right: 1px solid var(--od-border) !important;
padding: 16px 12px !important;
overflow-y: auto !important;
background: rgba(255, 255, 255, 0.01) !important;
display: flex !important;
flex-direction: column !important;
}
.od-nav-item {
padding: 10px 14px !important;
border-radius: 10px !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 10px !important;
font-size: 13px !important;
color: var(--od-text-secondary, #94a3b8) !important;
transition: all 0.2s !important;
margin-bottom: 4px !important;
background: transparent !important;
border: none !important;
text-align: left !important;
width: 100% !important;
}
.od-nav-item:hover {
background: rgba(255, 255, 255, 0.04) !important;
color: var(--od-text-primary, #fff) !important;
}
.od-nav-item.active {
background: rgba(var(--od-accent-rgb, 108, 92, 231), 0.15) !important;
color: var(--od-accent, #6c5ce7) !important;
font-weight: 600 !important;
}
.od-content {
flex: 1 !important;
padding: 24px !important;
overflow-y: auto !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(var(--od-accent-rgb, 108, 92, 231), 0.3) transparent !important;
}
.od-tab-content {
animation: tabFadeIn 0.3s ease !important;
}
@keyframes tabFadeIn {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
.od-section-title {
font-size: 15px !important;
font-weight: 600 !important;
color: var(--od-text-primary, #fff) !important;
margin-bottom: 18px !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
border-bottom: 1px solid var(--od-border) !important;
padding-bottom: 8px !important;
}
.od-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
gap: 16px !important;
margin-bottom: 24px !important;
}
.od-card {
background: var(--od-bg-secondary) !important;
border: 1px solid var(--od-border) !important;
border-radius: var(--od-radius, 16px) !important;
padding: 18px !important;
transition: all 0.2s !important;
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
}
.od-card:hover {
background: var(--od-bg-tertiary) !important;
border-color: rgba(var(--od-accent-rgb), 0.2) !important;
transform: translateY(-2px) !important;
box-shadow: var(--od-shadow) !important;
}
.od-card-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
}
.od-card-title {
font-size: 13px !important;
font-weight: 600 !important;
color: var(--od-text-primary) !important;
}
.od-card-description {
font-size: 11px !important;
color: var(--od-text-secondary) !important;
line-height: 1.4 !important;
}
/* Accent Color Grid */
.od-color-grid {
display: grid !important;
grid-template-columns: repeat(6, 1fr) !important;
gap: 10px !important;
margin: 8px 0 !important;
}
.od-color-circle {
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
cursor: pointer !important;
border: 2px solid transparent !important;
transition: transform 0.2s, border-color 0.2s !important;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3) !important;
}
.od-color-circle:hover {
transform: scale(1.1) !important;
}
.od-color-circle.selected {
border-color: #fff !important;
transform: scale(1.15) !important;
}
/* Toggle Switch */
.od-switch {
width: 44px !important;
height: 24px !important;
background: rgba(255, 255, 255, 0.1) !important;
border-radius: 12px !important;
position: relative !important;
cursor: pointer !important;
transition: background 0.3s !important;
flex-shrink: 0 !important;
}
.od-switch.active {
background: var(--od-accent) !important;
}
.od-switch::after {
content: '' !important;
position: absolute !important;
width: 20px !important;
height: 20px !important;
background: #fff !important;
border-radius: 50% !important;
top: 2px !important;
left: 2px !important;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
}
.od-switch.active::after {
transform: translateX(20px) !important;
}
/* Sliders */
.od-slider-container {
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
width: 100% !important;
}
.od-slider-header {
display: flex !important;
justify-content: space-between !important;
font-size: 11px !important;
color: var(--od-text-secondary) !important;
}
.od-slider {
-webkit-appearance: none !important;
width: 100% !important;
height: 4px !important;
border-radius: 2px !important;
background: rgba(255, 255, 255, 0.1) !important;
outline: none !important;
}
.od-slider::-webkit-slider-thumb {
-webkit-appearance: none !important;
width: 16px !important;
height: 16px !important;
border-radius: 50% !important;
background: var(--od-accent) !important;
cursor: pointer !important;
box-shadow: 0 0 0 4px rgba(var(--od-accent-rgb), 0.2) !important;
transition: box-shadow 0.2s !important;
}
.od-slider::-webkit-slider-thumb:hover {
box-shadow: 0 0 0 6px rgba(var(--od-accent-rgb), 0.3) !important;
}
/* Dropdowns & Text Inputs */
.od-select, .od-input {
background: rgba(0, 0, 0, 0.2) !important;
border: 1px solid var(--od-border) !important;
border-radius: 8px !important;
padding: 8px 12px !important;
color: var(--od-text-primary) !important;
font-size: 12px !important;
width: 100% !important;
outline: none !important;
box-sizing: border-box !important;
transition: border-color 0.2s !important;
}
.od-select:focus, .od-input:focus {
border-color: var(--od-accent) !important;
}
.od-select {
appearance: none !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important;
background-position: right 12px center !important;
padding-right: 36px !important;
}
/* Buttons */
.od-btn-primary {
background: linear-gradient(135deg, var(--od-accent) 0%, var(--od-accent-hover) 100%) !important;
color: #fff !important;
border: none !important;
border-radius: 10px !important;
padding: 10px 20px !important;
font-weight: 600 !important;
font-size: 12px !important;
cursor: pointer !important;
transition: all 0.2s !important;
box-shadow: 0 4px 12px rgba(var(--od-accent-rgb), 0.3) !important;
display: flex !important;
align-items: center !important;
gap: 6px !important;
}
.od-btn-primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 6px 16px rgba(var(--od-accent-rgb), 0.4) !important;
}
.od-btn-secondary {
background: rgba(255, 255, 255, 0.05) !important;
color: var(--od-text-secondary) !important;
border: 1px solid var(--od-border) !important;
border-radius: 10px !important;
padding: 10px 20px !important;
font-size: 12px !important;
cursor: pointer !important;
transition: all 0.2s !important;
}
.od-btn-secondary:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: var(--od-text-primary) !important;
}
/* Footer */
.od-footer {
padding: 16px 24px !important;
border-top: 1px solid var(--od-border) !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
background: rgba(255, 255, 255, 0.02) !important;
}
/* Live Card Preview */
.od-preview-card {
background: var(--od-glass, rgba(15, 15, 25, 0.85)) !important;
backdrop-filter: blur(var(--od-glass-blur, 16px)) !important;
border: 1px solid var(--od-border) !important;
border-radius: var(--od-radius, 16px) !important;
padding: 16px !important;
margin-top: 10px !important;
text-align: center !important;
box-shadow: var(--od-shadow) !important;
}
/* Connection status */
.od-status-badge {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
font-size: 11px !important;
font-weight: 500 !important;
padding: 4px 8px !important;
border-radius: 6px !important;
}
/* Stats Row */
.od-stats-row {
display: flex !important;
justify-content: space-between !important;
padding: 8px 0 !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.04) !important;
font-size: 12px !important;
}
.od-stats-label {
color: var(--od-text-secondary) !important;
}
.od-stats-value {
color: var(--od-text-primary) !important;
font-weight: 600 !important;
}
/* Mobile Responsive adjustments */
@media (max-width: 768px) {
.od-panel {
width: 100vw !important;
height: 100vh !important;
max-width: none !important;
max-height: none !important;
border-radius: 0 !important;
}
.od-body {
flex-direction: column !important;
}
.od-nav {
width: 100% !important;
border-right: none !important;
border-bottom: 1px solid var(--od-border) !important;
display: flex !important;
flex-direction: row !important;
overflow-x: auto !important;
padding: 8px !important;
gap: 8px !important;
}
.od-nav-item {
white-space: nowrap !important;
margin-bottom: 0 !important;
width: auto !important;
}
}
`;
const injectCSS = (cssText) => {
if (typeof GM_addStyle !== 'undefined') {
GM_addStyle(cssText);
return;
}
const inject = () => {
const style = document.createElement('style');
style.textContent = cssText;
(document.head || document.documentElement).appendChild(style);
};
if (document.head) {
inject();
} else {
new MutationObserver((_, obs) => {
if (document.head) { inject(); obs.disconnect(); }
}).observe(document.documentElement, { childList: true });
}
};
injectCSS(css);
const toastQueue = [];
let activeToast = null;
let toastTimeout = null;
const processNextToast = () => {
if (toastQueue.length === 0 || !document.body) return;
const { message, duration } = toastQueue[0];
const toast = document.createElement('div');
toast.className = 'ksmo-toast';
toast.textContent = message;
document.body.appendChild(toast);
activeToast = toast;
setTimeout(() => toast.classList.add('show'), 10);
toastTimeout = setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
activeToast = null;
toastQueue.shift();
processNextToast();
}, 300);
}, duration);
};
const showToast = (message, duration = 4000) => {
toastQueue.push({ message, duration });
if (toastQueue.length === 1) {
processNextToast();
} else if (activeToast) {
clearTimeout(toastTimeout);
activeToast.classList.remove('show');
setTimeout(() => {
if (activeToast) {
activeToast.remove();
activeToast = null;
}
// NÃO shift() aqui — processNextToast já gerencia a fila via toastQueue[0]
processNextToast();
}, 200);
}
};
const formatBytes = (bytes) => {
if (!bytes) return '';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
};
const utils = {
getCodec: (mimeType) => {
const mime = (mimeType || '').toLowerCase();
if (mime.includes('webm')) return 'WebM';
if (mime.includes('mp4')) return 'MP4';
if (mime.includes('ogg')) return 'Ogg';
return 'MP4';
}
};
const cacheManager = {
sizeCache: new Map(),
embedCache: new Map(),
setFileSize: function (url, size) {
if (this.sizeCache.has(url)) {
this.sizeCache.delete(url);
}
this.sizeCache.set(url, size);
if (this.sizeCache.size > 200) {
const firstKey = this.sizeCache.keys().next().value;
this.sizeCache.delete(firstKey);
}
},
getFileSize: function (url) {
return this.sizeCache.get(url);
},
hasFileSize: function (url) {
return this.sizeCache.has(url);
},
setEmbed: function (videoId, data) {
if (this.embedCache.has(videoId)) {
this.embedCache.delete(videoId);
}
this.embedCache.set(videoId, data);
if (this.embedCache.size > 50) {
const firstKey = this.embedCache.keys().next().value;
this.embedCache.delete(firstKey);
}
},
getEmbed: function (videoId) {
return this.embedCache.get(videoId);
},
hasEmbed: function (videoId) {
return this.embedCache.has(videoId);
}
};
const DownloadManager = {
/**
* Downloads a file, trying GM_download first, then GM_xmlhttpRequest with CORS proxies.
* Stores the request handle on the queueItem (if provided) for per-item cancellation.
*/
download: (url, filename, onProgress, onComplete, onError, queueItem) => {
let proxyIndex = -1;
const attemptDownload = (currentUrl) => {
const useGMDownload = typeof GM_download !== 'undefined';
const proxies = getCorsProxies();
if (useGMDownload && proxyIndex === -1) {
const req = GM_download({
url: currentUrl,
name: filename,
onload: () => {
if (queueItem) queueItem._request = null;
onComplete();
},
onerror: (err) => {
if (queueItem) queueItem._request = null;
handleError(err);
},
onprogress: (e) => {
if (e.total) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
}
});
if (queueItem) queueItem._request = req;
} else {
const req = GM_xmlhttpRequest({
method: 'GET',
url: currentUrl,
responseType: 'blob',
onprogress: (e) => {
if (e.total) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
},
onload: (resp) => {
if (queueItem) queueItem._request = null;
if (resp.status === 200) {
const blob = resp.response;
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(blobUrl);
onComplete();
} else {
handleError(new Error(`Status ${resp.status}`));
}
},
onerror: (err) => {
if (queueItem) queueItem._request = null;
handleError(err);
}
});
if (queueItem) queueItem._request = req;
}
};
const handleError = (err) => {
proxyIndex++;
const proxies = getCorsProxies();
if (proxyIndex < proxies.length) {
const nextUrl = proxies[proxyIndex](url);
attemptDownload(nextUrl);
} else {
onError(err);
}
};
attemptDownload(url);
},
cancelItem: (queueItem) => {
if (queueItem && queueItem._request && typeof queueItem._request.abort === 'function') {
queueItem._request.abort();
}
if (queueItem) queueItem._request = null;
}
};
const updateFabDownloadState = () => {
const fab = document.getElementById('ksmo-floating-dl');
if (fab) {
if (DownloadQueue.active) {
fab.classList.add('downloading');
} else {
fab.classList.remove('downloading');
}
}
};
const DownloadQueue = {
list: [],
active: null,
add: (url, filename, onProgress, onComplete, onError) => {
const item = { url, filename, onProgress, onComplete, onError, cancelled: false, _request: null };
DownloadQueue.list.push(item);
if (!DownloadQueue.active) {
DownloadQueue.processNext();
}
},
processNext: () => {
updateFabDownloadState();
if (DownloadQueue.list.length === 0) {
DownloadQueue.active = null;
updateFabDownloadState();
return;
}
const item = DownloadQueue.list.shift();
DownloadQueue.active = item;
DownloadManager.download(
item.url,
item.filename,
item.onProgress,
() => {
if (item.cancelled) return;
item.onComplete();
incrementDownloadStats(0);
DownloadQueue.active = null;
setTimeout(() => DownloadQueue.processNext(), 0);
},
(err) => {
if (item.cancelled) return;
item.onError(err);
DownloadQueue.active = null;
setTimeout(() => DownloadQueue.processNext(), 0);
},
item
);
},
clear: () => {
if (DownloadQueue.active) {
DownloadQueue.active.cancelled = true;
DownloadManager.cancelItem(DownloadQueue.active);
}
DownloadQueue.list.forEach(item => {
item.cancelled = true;
});
DownloadQueue.list = [];
DownloadQueue.active = null;
updateFabDownloadState();
}
};
const pendingSizes = new Map();
const triggerSizeCallbacks = (targetUrl, size) => {
if (size !== null) {
cacheManager.setFileSize(targetUrl, size);
}
const list = pendingSizes.get(targetUrl) || [];
pendingSizes.delete(targetUrl);
list.forEach(cb => cb(size));
};
const fetchFileSize = (url, callback) => {
if (cacheManager.hasFileSize(url)) {
callback(cacheManager.getFileSize(url));
return;
}
if (pendingSizes.has(url)) {
pendingSizes.get(url).push(callback);
return;
}
pendingSizes.set(url, [callback]);
if (typeof GM_xmlhttpRequest === 'undefined') {
triggerSizeCallbacks(url, null);
return;
}
const attemptRangeRequest = () => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: {
'Range': 'bytes=0-0'
},
onload: (rangeResp) => {
const rangeLen = rangeResp.responseHeaders.match(/content-range:\s*bytes\s*\d+-\d+\/(\d+)/i) ||
rangeResp.responseHeaders.match(/content-length:\s*(\d+)/i);
triggerSizeCallbacks(url, rangeLen ? parseInt(rangeLen[1], 10) : null);
},
onerror: () => triggerSizeCallbacks(url, null)
});
};
GM_xmlhttpRequest({
method: 'HEAD',
url: url,
onload: (resp) => {
const len = resp.responseHeaders.match(/content-length:\s*(\d+)/i);
if (len && len[1]) {
triggerSizeCallbacks(url, parseInt(len[1], 10));
} else {
attemptRangeRequest();
}
},
onerror: () => {
attemptRangeRequest();
}
});
};
const convertXmlToSrt = (xmlString) => {
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
const texts = xmlDoc.getElementsByTagName('text');
let srt = '';
for (let i = 0; i < texts.length; i++) {
const node = texts[i];
const start = parseFloat(node.getAttribute('start') || '0');
const dur = parseFloat(node.getAttribute('dur') || '0');
const text = node.textContent.replace(/<[^>]*>/g, '');
const formatTime = (seconds) => {
const date = new Date(null);
date.setSeconds(seconds);
const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0');
return date.toISOString().substr(11, 8) + ',' + ms;
};
srt += `${i + 1}\n`;
srt += `${formatTime(start)} --> ${formatTime(start + dur)}\n`;
srt += `${text}\n\n`;
}
return srt;
} catch (e) {
return '';
}
};
/**
* Extracts a JSON object assigned to a specific variable name inside a string of HTML.
* Uses a bracket-balancing parser to accurately locate the closing brace of the JSON object,
* handling string literals (with backslash escapes) to avoid matching inner string braces.
*/
const extractJsonFromHtml = (html, varName) => {
const pattern = new RegExp(varName + '\\s*=\\s*\\{');
const match = html.match(pattern);
if (!match) return null;
const startIndex = match.index + match[0].length - 1;
let braceCount = 0;
let inString = false;
let escape = false;
for (let i = startIndex; i < html.length; i++) {
const char = html[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"' || char === "'") {
if (!inString) {
inString = char;
} else if (inString === char) {
inString = false;
}
}
if (!inString) {
if (char === '{') braceCount++;
if (char === '}') {
braceCount--;
if (braceCount === 0) {
const jsonStr = html.slice(startIndex, i + 1);
try {
return JSON.parse(jsonStr);
} catch (e) {
return null;
}
}
}
}
}
return null;
};
const signatureDecipherer = {
cachedDecipher: null,
fetching: false,
callbacks: [],
findBaseJs: () => {
const script = Array.from(document.querySelectorAll('script')).map(s => s.src).find(src => src.includes('/s/player/') && src.includes('/base.js'));
return script || null;
},
parseDecipherFunc: (code) => {
const decipherMatch = code.match(/([a-zA-Z0-9$_]+)=function\([a-zA-Z]\)\{[a-zA-Z]=[a-zA-Z]\.split\(""\);([\s\S]+?);return [a-zA-Z]\.join\(""\)\}/);
if (!decipherMatch) return null;
const funcBody = decipherMatch[2];
const objMatch = funcBody.match(/([a-zA-Z0-9$_]+)\.[a-zA-Z0-9$_]+\([a-zA-Z],[0-9]+\)/);
if (!objMatch) return null;
const objName = objMatch[1];
const objNameEscaped = objName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const objRegex = new RegExp(`var\\s+${objNameEscaped}\\s*=\\s*\\{([\\s\\S]+?)\\};`);
const objDefMatch = code.match(objRegex);
if (!objDefMatch) return null;
const objDef = objDefMatch[1];
const methods = {};
const methodMatches = objDef.matchAll(/([a-zA-Z0-9$_]+)\s*:\s*function\(([^)]+)\)\{([^}]+)\}/g);
for (const match of methodMatches) {
const name = match[1];
const body = match[3];
if (body.includes('reverse')) {
methods[name] = 'reverse';
} else if (body.includes('splice')) {
methods[name] = 'slice';
} else {
methods[name] = 'swap';
}
}
const steps = [];
const stepMatches = funcBody.matchAll(/([a-zA-Z0-9$_]+)\.([a-zA-Z0-9$_]+)\([a-zA-Z],([0-9]+)\)/g);
for (const match of stepMatches) {
const mName = match[2];
const param = parseInt(match[3], 10);
const action = methods[mName];
if (action) {
steps.push({ action, param });
}
}
return steps;
},
buildDecipherFunc: (steps) => {
return (sig) => {
const arr = sig.split('');
steps.forEach(step => {
if (step.action === 'reverse') {
arr.reverse();
} else if (step.action === 'slice') {
arr.splice(0, step.param);
} else if (step.action === 'swap') {
const temp = arr[0];
arr[0] = arr[step.param % arr.length];
arr[step.param % arr.length] = temp;
}
});
return arr.join('');
};
},
decipher: (sig, callback) => {
if (signatureDecipherer.cachedDecipher) {
try {
callback(signatureDecipherer.cachedDecipher(sig));
} catch (e) {
callback(null);
}
return;
}
const baseJs = signatureDecipherer.findBaseJs();
if (!baseJs) {
callback(null);
return;
}
const cachedUrl = getSetting('ksmo_youtube_base_url', null);
const cachedSteps = getSetting('ksmo_youtube_decipher_steps', null);
if (cachedUrl === baseJs && cachedSteps) {
try {
const steps = JSON.parse(cachedSteps);
const dec = signatureDecipherer.buildDecipherFunc(steps);
signatureDecipherer.cachedDecipher = dec;
callback(dec(sig));
return;
} catch (e) { }
}
signatureDecipherer.callbacks.push({ sig, callback });
if (signatureDecipherer.fetching) return;
signatureDecipherer.fetching = true;
GM_xmlhttpRequest({
method: 'GET',
url: baseJs,
onload: (resp) => {
signatureDecipherer.fetching = false;
try {
const steps = signatureDecipherer.parseDecipherFunc(resp.responseText);
if (steps && steps.length > 0) {
setSetting('ksmo_youtube_base_url', baseJs);
setSetting('ksmo_youtube_decipher_steps', JSON.stringify(steps));
const dec = signatureDecipherer.buildDecipherFunc(steps);
signatureDecipherer.cachedDecipher = dec;
while (signatureDecipherer.callbacks.length) {
const item = signatureDecipherer.callbacks.shift();
try {
item.callback(dec(item.sig));
} catch (e) {
item.callback(null);
}
}
} else {
while (signatureDecipherer.callbacks.length) {
signatureDecipherer.callbacks.shift().callback(null);
}
}
} catch (e) {
while (signatureDecipherer.callbacks.length) {
signatureDecipherer.callbacks.shift().callback(null);
}
}
},
onerror: () => {
signatureDecipherer.fetching = false;
while (signatureDecipherer.callbacks.length) {
signatureDecipherer.callbacks.shift().callback(null);
}
}
});
}
};
/**
* Shared base.js fetcher — avoids duplicate network requests when both
* signatureDecipherer and nTransformDecipherer need the same file.
*/
const baseJsFetcher = {
cachedCode: null,
cachedUrl: null,
fetching: false,
callbacks: [],
fetch: (callback) => {
const baseJs = signatureDecipherer.findBaseJs();
if (!baseJs) { callback(null, null); return; }
if (baseJsFetcher.cachedUrl === baseJs && baseJsFetcher.cachedCode) {
callback(baseJsFetcher.cachedCode, baseJs);
return;
}
baseJsFetcher.callbacks.push(callback);
if (baseJsFetcher.fetching) return;
baseJsFetcher.fetching = true;
GM_xmlhttpRequest({
method: 'GET',
url: baseJs,
onload: (resp) => {
baseJsFetcher.fetching = false;
baseJsFetcher.cachedCode = resp.responseText;
baseJsFetcher.cachedUrl = baseJs;
while (baseJsFetcher.callbacks.length) {
baseJsFetcher.callbacks.shift()(resp.responseText, baseJs);
}
},
onerror: () => {
baseJsFetcher.fetching = false;
baseJsFetcher.cachedCode = null;
while (baseJsFetcher.callbacks.length) {
baseJsFetcher.callbacks.shift()(null, null);
}
}
});
}
};
/**
* YouTube nsig (n-parameter throttle transform) decipherer.
*
* Approach B: Extract the transform function as a string from base.js and
* execute via new Function(). Falls back to the original n value (slower
* download, but functional) when extraction or execution fails.
*/
const nTransformDecipherer = {
cachedTransform: null,
fetching: false,
callbacks: [],
/**
* Locates the function name responsible for transforming the 'n' parameter
* inside YouTube's base.js player code.
*/
findNTransformName: (code) => {
const patterns = [
/\.get\("n"\)\)&&\(b=([a-zA-Z0-9$]+)(?:\[(\d+)\])?\([a-zA-Z0-9]\)/,
/\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\(/,
/\bc\s*=\s*a\.get\("n"\)\)&&\(c=([a-zA-Z0-9$]+)\(/,
/\b(?:b=([a-zA-Z0-9$]{2,})\(b\),c\.set\("n",b\))/,
];
for (const pattern of patterns) {
const match = code.match(pattern);
if (match) {
let name = match[1];
if (match[2] !== undefined) {
const esc = name.replace(/\$/g, '\\$');
const arrMatch = code.match(new RegExp(`var\\s+${esc}\\s*=\\s*\\[([a-zA-Z0-9$]+)\\]`));
if (arrMatch) name = arrMatch[1];
}
return name;
}
}
return null;
},
/**
* Extracts the full function body using bracket-balancing (same algorithm
* as extractJsonFromHtml) to handle nested structures safely.
*/
extractFunction: (code, funcName) => {
const esc = funcName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const startPatterns = [
new RegExp(`${esc}\\s*=\\s*function\\s*\\(`),
new RegExp(`function\\s+${esc}\\s*\\(`)
];
let funcStartIdx = -1;
for (const pat of startPatterns) {
const m = code.match(pat);
if (m) { funcStartIdx = m.index; break; }
}
if (funcStartIdx === -1) return null;
const braceStart = code.indexOf('{', funcStartIdx);
if (braceStart === -1) return null;
let depth = 0, inStr = false, isEsc = false;
for (let i = braceStart; i < code.length; i++) {
const ch = code[i];
if (isEsc) { isEsc = false; continue; }
if (ch === '\\') { isEsc = true; continue; }
if (ch === '"' || ch === "'") {
if (!inStr) inStr = ch;
else if (inStr === ch) inStr = false;
}
if (!inStr) {
if (ch === '{') depth++;
if (ch === '}') {
depth--;
if (depth === 0) {
const paramMatch = code.slice(funcStartIdx, braceStart).match(/\(([^)]*)\)/);
const param = paramMatch ? paramMatch[1].trim() : 'a';
const body = code.slice(braceStart + 1, i);
return { param, body };
}
}
}
}
return null;
},
buildTransformFunc: (param, body) => {
try {
return new Function(param, body);
} catch (e) {
if (e.name === 'SecurityError' || e.message.includes('CSP') || e.message.includes('unsafe-eval')) {
console.warn('[OmniDown] Content Security Policy (CSP) blocked dynamic decipher execution. Falling back to slow stream.', e);
} else {
console.error('[OmniDown] Syntax error building transform function.', e);
}
return null;
}
},
transform: (n, callback) => {
if (nTransformDecipherer.cachedTransform) {
try {
const result = nTransformDecipherer.cachedTransform(n);
callback(typeof result === 'string' ? result : n);
} catch (e) {
callback(n);
}
return;
}
const baseJs = signatureDecipherer.findBaseJs();
const cachedUrl = getSetting('ksmo_youtube_base_url_n', null);
const cachedFunc = getSetting('ksmo_youtube_n_func', null);
if (baseJs && cachedUrl === baseJs && cachedFunc) {
try {
const { param, body } = JSON.parse(cachedFunc);
const fn = nTransformDecipherer.buildTransformFunc(param, body);
if (fn) {
nTransformDecipherer.cachedTransform = fn;
const result = fn(n);
callback(typeof result === 'string' ? result : n);
return;
}
} catch (e) { }
}
nTransformDecipherer.callbacks.push({ n, callback });
if (nTransformDecipherer.fetching) return;
nTransformDecipherer.fetching = true;
baseJsFetcher.fetch((code, fetchedUrl) => {
nTransformDecipherer.fetching = false;
if (!code) {
while (nTransformDecipherer.callbacks.length) {
const item = nTransformDecipherer.callbacks.shift();
item.callback(item.n);
}
return;
}
try {
const funcName = nTransformDecipherer.findNTransformName(code);
if (funcName) {
const extracted = nTransformDecipherer.extractFunction(code, funcName);
if (extracted) {
const fn = nTransformDecipherer.buildTransformFunc(extracted.param, extracted.body);
if (fn) {
setSetting('ksmo_youtube_base_url_n', fetchedUrl);
setSetting('ksmo_youtube_n_func', JSON.stringify(extracted));
nTransformDecipherer.cachedTransform = fn;
while (nTransformDecipherer.callbacks.length) {
const item = nTransformDecipherer.callbacks.shift();
try {
const result = fn(item.n);
item.callback(typeof result === 'string' ? result : item.n);
} catch (e) {
item.callback(item.n);
}
}
return;
}
}
}
} catch (e) { }
// Fallback: couldn't parse — use original n (slow but functional)
while (nTransformDecipherer.callbacks.length) {
const item = nTransformDecipherer.callbacks.shift();
item.callback(item.n);
}
});
}
};
/**
* Applies the n-parameter throttle transform to a YouTube streaming URL.
* If no 'n' param exists or transform fails, returns the original URL.
*/
const applyNTransform = (url, callback) => {
try {
const u = new URL(url);
const n = u.searchParams.get('n');
if (!n) { callback(url); return; }
nTransformDecipherer.transform(n, (transformedN) => {
if (transformedN && transformedN !== n) {
u.searchParams.set('n', transformedN);
}
callback(u.toString());
});
} catch (e) {
callback(url);
}
};
const decipherUrl = (cipher, callback) => {
let parsed = new URLSearchParams(cipher);
let url = parsed.get('url');
let s = parsed.get('s');
let sp = parsed.get('sp') || 'sig';
if (!url || !s) {
const urlMatch = cipher.match(/url=([^&]+)/);
const sMatch = cipher.match(/s=([^&]+)/);
const spMatch = cipher.match(/sp=([^&]+)/);
if (urlMatch) url = decodeURIComponent(urlMatch[1].replace(/\+/g, ' '));
if (sMatch) s = decodeURIComponent(sMatch[1].replace(/\+/g, ' '));
if (spMatch) sp = decodeURIComponent(spMatch[1].replace(/\+/g, ' '));
}
if (!url || !s) {
callback(null);
return;
}
signatureDecipherer.decipher(s, (decipheredS) => {
if (decipheredS) {
try {
const u = new URL(url);
// Remove any existing signature params to avoid duplication
u.searchParams.delete('sig');
u.searchParams.delete('signature');
u.searchParams.delete(sp);
u.searchParams.set(sp, decipheredS);
callback(u.toString());
} catch (e) {
callback(null);
}
} else {
callback(null);
}
});
};
const fetchEmbedPlayerResponse = (videoId, callback) => {
if (cacheManager.hasEmbed(videoId)) {
callback(cacheManager.getEmbed(videoId));
return;
}
if (typeof GM_xmlhttpRequest === 'undefined') {
callback(null);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.youtube.com/embed/${videoId}`,
timeout: 8000,
onload: (resp) => {
const data = extractJsonFromHtml(resp.responseText, 'ytInitialPlayerResponse');
if (data) {
cacheManager.setEmbed(videoId, data);
callback(data);
} else {
callback(null);
}
},
onerror: () => callback(null),
ontimeout: () => {
console.warn('[OmniDown] YouTube embed player response request timed out.');
callback(null);
}
});
};
const twitterExtractor = () => {
const source = document.querySelector('[data-testid="videoPlayer"] video source');
let url = source?.src;
if (!url) {
const video = document.querySelector('video') || document.querySelector('[data-testid="videoPlayer"] video');
url = video?.src;
}
if (!url || url.startsWith('blob:')) return null;
return {
title: document.title || getText('labelTwitter'),
formats: [{ quality: 'Source', url, type: 'video/mp4', label: getText('labelTwitter') }]
};
};
const extractors = {
'youtube.com': () => {
try {
const myWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const player = document.querySelector('#movie_player') || myWin.movie_player;
let response = player?.getPlayerResponse?.() || myWin.ytInitialPlayerResponse;
if (!response) {
const scripts = Array.from(document.querySelectorAll('script'));
for (const script of scripts) {
const data = extractJsonFromHtml(script.textContent, 'ytInitialPlayerResponse');
if (data) {
response = data;
break;
}
}
}
if (!response) return null;
const title = response.videoDetails?.title || document.title || getText('labelYoutube');
const streaming = response.streamingData;
if (!streaming) return null;
const formats = [];
const muxed = streaming.formats || [];
const adaptive = streaming.adaptiveFormats || [];
muxed.forEach(f => {
let url = f.url;
let cipher = null;
if (!url && f.signatureCipher) {
cipher = f.signatureCipher;
}
formats.push({
quality: f.qualityLabel || `${f.width}p`,
url: url || '',
cipher: cipher,
type: f.mimeType?.split(';')[0] || 'video/mp4',
label: cipher ? `${getText('muxed')} [${utils.getCodec(f.mimeType)}] (${getText('deciphering')})` : `${getText('muxed')} [${utils.getCodec(f.mimeType)}]`
});
});
adaptive.forEach(f => {
let url = f.url;
let cipher = null;
if (!url && f.signatureCipher) {
cipher = f.signatureCipher;
}
const isVideo = f.mimeType?.includes('video');
formats.push({
quality: f.qualityLabel || `${f.width}p`,
url: url || '',
cipher: cipher,
type: f.mimeType?.split(';')[0] || 'video/mp4',
label: cipher ? (isVideo ? `${getText('adaptiveNoAudio').replace(/\s*\(.*\)/, '')} [${utils.getCodec(f.mimeType)}] (${getText('deciphering')})` : `${getText('audioOnly')} [${utils.getCodec(f.mimeType)}] (${getText('deciphering')})`) : (isVideo ? `${getText('adaptiveNoAudio')} [${utils.getCodec(f.mimeType)}]` : `${getText('audioOnly')} [${utils.getCodec(f.mimeType)}]`)
});
});
const captionTracks = response.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
captionTracks.forEach(track => {
if (track.baseUrl) {
formats.push({
quality: `${track.name.simpleText || track.name.runs?.[0]?.text || getText('subtitles')}`,
url: track.baseUrl,
type: 'text/srt',
label: getText('subtitleTrack'),
isSubtitle: true
});
}
});
return { title, formats };
} catch (e) {
return null;
}
},
'tiktok.com': () => {
const activeVideo = getActiveVideoElement();
const videoIdMatch = window.location.href.match(/\/video\/(\d+)/);
let videoId = videoIdMatch ? videoIdMatch[1] : tiktokState.currentVideoId;
if (!videoId && activeVideo) {
let parent = activeVideo.parentElement;
while (parent && parent !== document.body) {
const links = parent.querySelectorAll('a[href*="/video/"]');
for (const link of links) {
const match = link.href.match(/\/video\/(\d+)/);
if (match) {
videoId = match[1];
break;
}
}
if (videoId) break;
parent = parent.parentElement;
}
}
const data = videoId ? tiktokState.videoData.get(videoId) : null;
let title = (data && data.desc) ? data.desc : null;
let url = (data && data.url) ? data.url : (activeVideo?.src || null);
const pageUrl = videoId ? `https://www.tiktok.com/video/${videoId}` : window.location.href;
const myWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (!url || url.startsWith('blob:') || !title) {
const sigiEl = document.querySelector('#SIGI_STATE') ||
document.querySelector('script[data-sigi-persisted-state]');
if (sigiEl) {
try {
const sigi = JSON.parse(sigiEl.textContent);
const itemModule = sigi.ItemModule || {};
if (videoId && itemModule[videoId]) {
const item = itemModule[videoId];
if (!url || url.startsWith('blob:')) {
url = item.video?.playAddr || item.video?.downloadAddr || url;
}
if (!title) {
title = item.desc || title;
}
} else if (!title || !url || url.startsWith('blob:')) {
const firstKey = Object.keys(itemModule)[0];
if (firstKey) {
const item = itemModule[firstKey];
if (!url || url.startsWith('blob:')) {
url = item.video?.playAddr || item.video?.downloadAddr || url;
}
if (!title) {
title = item.desc || title;
}
}
}
} catch (e) { }
}
}
if (!url || url.startsWith('blob:') || !title) {
if (myWin.__INITIAL_STATE__) {
const d = myWin.__INITIAL_STATE__;
if (!url || url.startsWith('blob:')) {
url = d.videoData?.playAddr || url;
}
if (!title) {
title = d.videoData?.desc || title;
}
}
}
if (!url || url.startsWith('blob:') || !title) {
if (myWin.__UNIVERSAL_DATA__) {
try {
const udata = myWin.__UNIVERSAL_DATA__;
const defaultScope = udata['webapp.video-detail']?.itemInfo?.itemStruct;
if (defaultScope) {
if (!url || url.startsWith('blob:')) {
url = defaultScope.video?.playAddr || defaultScope.video?.downloadAddr || url;
}
if (!title) {
title = defaultScope.desc || title;
}
}
} catch (e) { }
}
}
if (!url || url.startsWith('blob:') || !title) {
const nextDataScript = document.getElementById('__NEXT_DATA__');
if (nextDataScript) {
try {
const nd = JSON.parse(nextDataScript.textContent);
const itemStruct = nd.props?.pageProps?.itemInfo?.itemStruct;
if (itemStruct) {
if (!url || url.startsWith('blob:')) {
url = itemStruct.video?.playAddr || itemStruct.video?.downloadAddr || url;
}
if (!title) {
title = itemStruct.desc || title;
}
}
} catch (e) { }
}
}
if (!url || url.startsWith('blob:')) {
const container = document.querySelector('[class*="xgplayer-container"]');
const v = container?.querySelector('video');
url = v?.src || url;
}
if (!title) {
title = document.title || getText('labelTiktok');
}
if (!url || url.startsWith('blob:')) return null;
return {
title,
formats: [{ quality: 'Source', url, pageUrl, type: 'video/mp4', label: getText('labelTiktok') }]
};
},
'instagram.com': () => {
// Collect all videos in the current visible context (carousel support)
const allVideos = Array.from(document.querySelectorAll('article video, [role="dialog"] video, main video'));
const activeVideo = getActiveVideoElement();
// Sort so that the active video is first
if (activeVideo && allVideos.includes(activeVideo)) {
const index = allVideos.indexOf(activeVideo);
if (index > 0) {
allVideos.splice(index, 1);
allVideos.unshift(activeVideo);
}
}
const title = document.title || getText('labelInstagram');
const formats = [];
const seenUrls = new Set();
allVideos.forEach((video, idx) => {
let url = video.src;
if (!url) {
const source = video.querySelector('source');
url = source?.src;
}
if (!url) return;
const isBlob = url.startsWith('blob:');
const key = isBlob ? `blob_${idx}` : url;
if (seenUrls.has(key)) return;
seenUrls.add(key);
// Find the specific post URL for this video card/feed element
let pageUrl = window.location.href;
let parent = video.parentElement;
while (parent && parent !== document.body) {
const postLink = parent.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]');
if (postLink && postLink.href) {
pageUrl = postLink.href;
break;
}
parent = parent.parentElement;
}
// Label the first video as the currently viewed one if carousel has multiple elements
const isCurrent = video === activeVideo;
const labelSuffix = allVideos.length > 1 ? ` #${idx + 1}${isCurrent ? ` (${getText('currentLabel')})` : ''}` : '';
formats.push({
quality: allVideos.length > 1 ? `${getText('download')} ${idx + 1}${isCurrent ? ` (${getText('currentLabel')})` : ''}` : 'Source',
url: isBlob ? '' : url,
pageUrl: pageUrl,
type: 'video/mp4',
label: isBlob ? getText('labelHlsBlob') : `${getText('labelInstagram')}${labelSuffix}`,
isHLS: isBlob
});
});
// Fallback: preload link
if (formats.length === 0) {
const preload = document.querySelector('link[rel="preload"][as="video"]');
if (preload?.href) {
formats.push({
quality: 'Source',
url: preload.href,
pageUrl: window.location.href,
type: 'video/mp4',
label: getText('labelInstagram')
});
}
}
if (formats.length === 0) return null;
return { title, formats };
},
'x.com': twitterExtractor,
'twitter.com': twitterExtractor,
'facebook.com': () => {
const video = document.querySelector('video');
if (!video || !video.src || video.src.startsWith('blob:')) return null;
return {
title: document.title || getText('labelFacebook'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelFacebook') }]
};
},
'threads.net': () => {
const video = document.querySelector('video');
if (!video || !video.src || video.src.startsWith('blob:')) return null;
return {
title: document.title || getText('labelThreads'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelThreads') }]
};
},
'douyin.com': () => {
const video = document.querySelector('video');
if (!video || !video.src) return null;
return {
title: document.title || getText('labelDouyin'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelDouyin') }]
};
},
'xiaohongshu.com': () => {
const video = document.querySelector('video');
if (!video || !video.src) return null;
return {
title: document.title || getText('labelXhs'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelXhs') }]
};
},
'ted.com': () => {
const video = document.querySelector('video');
if (!video || !video.src) return null;
return {
title: document.title || getText('labelTed'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelTed') }]
};
},
'snapchat.com': () => {
const video = document.querySelector('video');
if (!video || !video.src) return null;
return {
title: document.title || getText('labelSnapchat'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelSnapchat') }]
};
},
'pinterest.com': () => {
const video = document.querySelector('video');
if (!video || !video.src) return null;
return {
title: document.title || getText('labelPinterest'),
formats: [{ quality: 'Source', url: video.src, type: 'video/mp4', label: getText('labelPinterest') }]
};
},
'spotify.com': () => {
const url = window.location.href;
const match = url.match(/track\/([a-zA-Z0-9]+)/);
if (match) {
return {
title: document.title || getText('labelSpotify'),
formats: [],
spotifyId: match[1]
};
}
return null;
},
'generic': () => {
const video = document.querySelector('video');
if (!video || !video.src || video.src.startsWith('blob:')) return null;
const isHLS = video.src.includes('.m3u8') || video.src.includes('m3u8');
return {
title: document.title || getText('download'),
formats: [{
quality: 'Source',
url: isHLS ? '' : video.src,
type: 'video/mp4',
label: isHLS ? getText('labelHlsBlob') : getText('labelHtml5Video'),
isHLS: isHLS
}]
};
}
};
const YOUTUBE_PLAYER_SELECTORS = [
'#movie_player video',
'.html5-video-player video',
'ytd-watch-flexy video',
'video[src*="googlevideo.com"]',
'video[src*="blob:"]'
];
const KNOWN_VIDEO_SITES = [
'youtube.com', 'tiktok.com', 'instagram.com', 'twitter.com',
'x.com', 'facebook.com', 'threads.net', 'douyin.com',
'xiaohongshu.com', 'ted.com', 'snapchat.com', 'pinterest.com', 'spotify.com'
];
let lastActiveVideoTime = 0;
let cachedActiveVideo = null;
const getActiveVideoElement = () => {
const now = Date.now();
if (now - lastActiveVideoTime < 150 && cachedActiveVideo && document.body.contains(cachedActiveVideo)) {
return cachedActiveVideo;
}
const videos = [];
// Find all videos in main document and shadow roots
const queue = [document.body];
const visitedNodes = new Set();
while (queue.length > 0) {
const node = queue.shift();
if (!node || visitedNodes.has(node)) continue;
visitedNodes.add(node);
const found = node.querySelectorAll?.('video');
if (found) {
found.forEach(v => videos.push(v));
}
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {
const el = walker.currentNode;
if (el.shadowRoot && !visitedNodes.has(el.shadowRoot)) {
queue.push(el.shadowRoot);
}
}
}
if (videos.length === 0) {
cachedActiveVideo = null;
lastActiveVideoTime = now;
return null;
}
if (videos.length === 1) {
cachedActiveVideo = videos[0];
lastActiveVideoTime = now;
return videos[0];
}
// 1. Try to find the playing/active video
const playing = videos.find(v => !v.paused && v.currentTime > 0);
if (playing) {
cachedActiveVideo = playing;
lastActiveVideoTime = now;
return playing;
}
// 2. Find the one with largest visible area in the viewport
let bestVideo = null;
let maxVisibleArea = 0;
videos.forEach(v => {
const rect = v.getBoundingClientRect();
const top = Math.max(0, rect.top);
const bottom = Math.min(window.innerHeight, rect.bottom);
const left = Math.max(0, rect.left);
const right = Math.min(window.innerWidth, rect.right);
if (bottom > top && right > left) {
const visibleArea = (bottom - top) * (right - left);
if (visibleArea > maxVisibleArea) {
maxVisibleArea = visibleArea;
bestVideo = v;
}
}
});
const result = bestVideo || videos[0];
cachedActiveVideo = result;
lastActiveVideoTime = now;
return result;
};
const findAnyVideo = () => {
if (window.location.hostname.includes('youtube.com')) {
for (const selector of YOUTUBE_PLAYER_SELECTORS) {
const v = document.querySelector(selector);
if (v) return v;
}
}
return getActiveVideoElement();
};
function getExtractor() {
const host = window.location.hostname;
for (const domain of Object.keys(extractors)) {
if (domain !== 'generic' && host.includes(domain)) {
return extractors[domain];
}
}
return extractors['generic'];
}
function getPlayerContainer(video) {
const specificPlayer =
video.closest('[data-uia="video-player"]') ||
video.closest('.watch-video') ||
video.closest('[class*="watch-video"]') ||
video.closest('[class*="nf-player"]') ||
video.closest('.html5-video-player') ||
video.closest('.video-player') ||
video.closest('[class*="video-player"]') ||
video.closest('[class*="stream-player"]') ||
video.closest('[data-testid*="player"]') ||
video.closest('[role="main"]') ||
video.closest('[id*="player"]');
const forbidden = ['BODY', 'HTML', 'MAIN', 'ARTICLE', 'SECTION'];
if (specificPlayer && !forbidden.includes(specificPlayer.tagName)) return specificPlayer;
let current = video.parentElement;
const keywords = ['player', 'video-player', 'movie_player', 'video-container', 'video-wrapper', 'player-container', 'vjs-control-bar', 'stream-player'];
while (current && !forbidden.includes(current.tagName)) {
const id = current.id || '';
const classes = Array.from(current.classList).join(' ').toLowerCase();
if (keywords.some(k => id.toLowerCase().includes(k) || classes.includes(k))) {
return current;
}
current = current.parentElement;
}
return video.parentElement && !forbidden.includes(video.parentElement.tagName) ? video.parentElement : video;
}
const containerListeners = new WeakMap();
const attachedVideos = new WeakSet();
function injectYouTubeControlButton() {
if (document.getElementById('ksmo-yt-control-btn')) return;
const player = document.querySelector('.html5-video-player');
if (!player) return;
const rightControls = player.querySelector('.ytp-right-controls');
if (!rightControls) return;
const btn = document.createElement('button');
btn.id = 'ksmo-yt-control-btn';
btn.className = 'ytp-button';
btn.setAttribute('aria-label', getText('download'));
btn.setAttribute('title', getText('nativeYtDlTitle'));
btn.style.display = 'inline-flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.verticalAlign = 'top';
btn.style.cursor = 'pointer';
btn.appendChild(createSvgIcon('download', { style: { width: '20px', height: '20px', margin: 'auto' } }));
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
openDownloadModal();
});
rightControls.prepend(btn);
}
function attachTriggerButton(video) {
if (video.dataset.ksmoDownloaderAttached) return;
if (video.offsetWidth < 50 || video.offsetHeight < 50) return;
const container = getPlayerContainer(video);
if (container.querySelector('.ksmo-dl-trigger')) {
video.dataset.ksmoDownloaderAttached = 'true';
return;
}
if (getSetting(SETTINGS_KEYS.showButton, true) === false) {
video.dataset.ksmoDownloaderAttached = 'true';
return;
}
if (!video.isConnected) {
return;
}
video.dataset.ksmoDownloaderAttached = 'true';
attachedVideos.add(video);
const trigger = document.createElement('div');
trigger.className = 'ksmo-dl-trigger';
trigger.setAttribute('aria-label', getText('floatingButtonLabel'));
trigger.appendChild(createSvgIcon('download', { width: '20', height: '20' }));
const computedStyle = window.getComputedStyle(container);
if (computedStyle.position === 'static') {
container.style.position = 'relative';
}
container.appendChild(trigger);
let hideTimeout;
const showTrigger = () => {
clearTimeout(hideTimeout);
trigger.classList.add('visible');
};
const hideTrigger = () => {
hideTimeout = setTimeout(() => {
trigger.classList.remove('visible');
}, 1200);
};
if (containerListeners.has(container)) {
containerListeners.get(container).abort();
}
const abController = new AbortController();
containerListeners.set(container, abController);
container.addEventListener('mouseenter', showTrigger, { capture: true, passive: true, signal: abController.signal });
container.addEventListener('mousemove', showTrigger, { capture: true, passive: true, signal: abController.signal });
container.addEventListener('mouseleave', hideTrigger, { capture: true, passive: true, signal: abController.signal });
container.addEventListener('touchstart', showTrigger, { capture: true, passive: true, signal: abController.signal });
trigger.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
openDownloadModal();
});
}
let activeModalBackdrop = null;
function renderQualityRow(f, info, progressContainer, fill, percentText, list) {
const row = document.createElement('div');
row.className = 'ksmo-quality-row';
const qInfo = document.createElement('div');
qInfo.className = 'ksmo-quality-info';
const name = document.createElement('div');
name.className = 'ksmo-quality-name';
name.textContent = f.quality;
const label = document.createElement('div');
label.className = 'ksmo-quality-label';
label.textContent = f.label;
qInfo.appendChild(name);
qInfo.appendChild(label);
if (!f.isSubtitle && f.url) {
fetchFileSize(f.url, (bytes) => {
if (bytes) {
label.textContent = `${f.label} • ${formatBytes(bytes)}`;
}
});
}
const actionsContainer = document.createElement('div');
actionsContainer.className = 'ksmo-actions-wrapper';
actionsContainer.style.display = 'flex';
actionsContainer.style.alignItems = 'center';
actionsContainer.style.gap = '8px';
const dlBtn = document.createElement('button');
dlBtn.className = 'ksmo-dl-btn';
dlBtn.setAttribute('aria-label', getText('ariaFormatDownload').replace('{quality}', f.quality));
dlBtn.textContent = getText('download');
actionsContainer.appendChild(dlBtn);
let cobaltRowBtn = null;
if (!f.isSubtitle && !f.isHLS && f.url && !f.url.startsWith('blob:')) {
cobaltRowBtn = document.createElement('button');
cobaltRowBtn.className = 'ksmo-secondary-btn';
cobaltRowBtn.setAttribute('aria-label', getText('ariaCobaltRow'));
cobaltRowBtn.textContent = getText('cobalt');
cobaltRowBtn.style.padding = '6px 12px';
cobaltRowBtn.style.fontSize = '13px';
cobaltRowBtn.style.borderColor = '#6c5ce7';
cobaltRowBtn.style.color = '#a29bfe';
cobaltRowBtn.addEventListener('click', () => {
let url = f.pageUrl || f.url;
if (!url || url.startsWith('blob:')) {
url = window.location.href;
}
if (window.location.hostname.includes('tiktok.com')) {
showTikTokCobaltConfirmation(url);
} else {
openCobaltUI(url);
}
});
actionsContainer.appendChild(cobaltRowBtn);
}
if (f.isHLS) {
dlBtn.textContent = getText('fallback');
dlBtn.addEventListener('click', () => {
showToast(getText('toastHlsDetected'), 3000);
setTimeout(() => {
triggerFallbackRedirect(window.location.href);
}, 1000);
});
} else {
if (f.cipher && !f.url) {
dlBtn.disabled = true;
dlBtn.style.opacity = '0.5';
dlBtn.textContent = getText('deciphering').replace('...', '');
let completed = false;
const timeoutId = setTimeout(() => {
if (!completed) {
completed = true;
dlBtn.disabled = false;
dlBtn.style.opacity = '1';
dlBtn.textContent = getText('fallback');
dlBtn.addEventListener('click', () => {
showToast(getText('toastYtCipherFail'), 3000);
setTimeout(() => {
triggerFallbackRedirect(window.location.href);
}, 1000);
}, { once: true });
f.label = f.label.replace('(Deciphering...)', '(Cipher Blocked)');
label.textContent = f.label;
}
}, 10000);
decipherUrl(f.cipher, (decryptedUrl) => {
if (completed) return;
completed = true;
clearTimeout(timeoutId);
if (decryptedUrl) {
f.url = decryptedUrl;
f.cipher = null;
dlBtn.disabled = false;
dlBtn.style.opacity = '1';
dlBtn.textContent = getText('download');
const cleanLabel = f.label.replace(' (Deciphering...)', '').replace(` (${getText('deciphering')})`, '');
f.label = cleanLabel;
label.textContent = cleanLabel;
fetchFileSize(decryptedUrl, (bytes) => {
if (bytes) {
label.textContent = `${cleanLabel} • ${formatBytes(bytes)}`;
}
});
// Recreate Cobalt row button after decryption if needed
if (cobaltRowBtn) cobaltRowBtn.remove();
if (!f.isSubtitle && !f.isHLS && decryptedUrl && !decryptedUrl.startsWith('blob:')) {
cobaltRowBtn = document.createElement('button');
cobaltRowBtn.className = 'ksmo-secondary-btn';
cobaltRowBtn.setAttribute('aria-label', getText('ariaCobaltRow'));
cobaltRowBtn.textContent = getText('cobalt');
cobaltRowBtn.style.padding = '6px 12px';
cobaltRowBtn.style.fontSize = '13px';
cobaltRowBtn.style.borderColor = '#6c5ce7';
cobaltRowBtn.style.color = '#a29bfe';
cobaltRowBtn.addEventListener('click', () => {
let url = f.pageUrl || decryptedUrl;
if (!url || url.startsWith('blob:')) {
url = window.location.href;
}
if (window.location.hostname.includes('tiktok.com')) {
showTikTokCobaltConfirmation(url);
} else {
openCobaltUI(url);
}
});
actionsContainer.appendChild(cobaltRowBtn);
}
} else {
dlBtn.disabled = false;
dlBtn.style.opacity = '1';
dlBtn.textContent = getText('fallback');
dlBtn.addEventListener('click', () => {
showToast(getText('toastYtCipherFail'), 3000);
setTimeout(() => {
triggerFallbackRedirect(window.location.href);
}, 1000);
}, { once: true });
f.label = f.label.replace('(Deciphering...)', '(Cipher Blocked)');
label.textContent = f.label;
}
});
}
dlBtn.addEventListener('click', () => {
if (!f.url) return;
progressContainer.style.display = 'flex';
fill.style.width = '0%';
percentText.textContent = '0%';
const sanitizedTitle = info.title.replace(/[\\/:*?"<>|]/g, '_');
if (f.isSubtitle) {
const subtitleReq = GM_xmlhttpRequest({
method: 'GET',
url: f.url,
onload: (resp) => {
if (DownloadQueue.active?.cancelled) return;
const srt = convertXmlToSrt(resp.responseText);
const blob = new Blob([srt], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${sanitizedTitle}_${f.quality}.srt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
progressContainer.style.display = 'none';
showToast(getText('toastSubtitlesSuccess'));
},
onerror: () => {
progressContainer.style.display = 'none';
showToast(getText('toastSubtitlesFail'));
}
});
return;
}
const extension = f.type.includes('video') ? 'mp4' : 'mp3';
const filename = `${sanitizedTitle}_${f.quality}.${extension}`;
const pName = progressContainer.querySelector('.ksmo-progress-name');
if (pName) {
pName.textContent = filename.length > 32 ? filename.substring(0, 30) + '...' : filename;
}
// Apply nsig transform for YouTube URLs before downloading
const isYouTube = window.location.hostname.includes('youtube.com');
const enqueueDownload = (finalUrl) => {
DownloadQueue.add(
finalUrl,
filename,
(percent) => {
fill.style.width = `${percent}%`;
percentText.textContent = `${percent}%`;
},
() => {
progressContainer.style.display = 'none';
showToast(getText('toastDownloadSuccess'));
},
(err) => {
progressContainer.style.display = 'none';
showToast(getText('toastDirectFail'), 5000);
if (getSetting(SETTINGS_KEYS.autoFallback, false)) {
if (confirm(getText('toastDirectFailConfirm'))) {
let fallbackUrl = f.pageUrl || f.url;
if (!fallbackUrl || fallbackUrl.startsWith('blob:')) {
fallbackUrl = window.location.href;
}
triggerFallbackRedirect(fallbackUrl);
}
}
}
);
};
if (isYouTube && typeof applyNTransform === 'function') {
applyNTransform(f.url, enqueueDownload);
} else {
enqueueDownload(f.url);
}
});
}
row.appendChild(qInfo);
row.appendChild(actionsContainer);
list.appendChild(row);
}
const exportPlaylist = () => {
const selectors = [
'ytd-playlist-video-renderer a#video-title',
'ytd-playlist-video-renderer a.yt-simple-endpoint',
'ytd-playlist-panel-video-renderer a.yt-simple-endpoint',
'a[href*="/watch?v="][href*="&list="]'
];
let links = [];
for (const selector of selectors) {
const found = Array.from(document.querySelectorAll(selector))
.map(a => a.href)
.filter(href => href && href.includes('/watch?v=') && href.includes('&list='));
if (found.length > 0) {
links = found;
break;
}
}
const parsedLinks = links.map(href => {
const url = new URL(href);
return `https://www.youtube.com/watch?v=${url.searchParams.get('v')}`;
});
const uniqueLinks = [...new Set(parsedLinks)];
if (uniqueLinks.length > 0) {
const blob = new Blob([uniqueLinks.join('\n')], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `playlist_${new URLSearchParams(window.location.search).get('list') || 'export'}.txt`;
a.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
showToast(getText('toastPlaylistExported').replace('{count}', uniqueLinks.length));
} else {
showToast(getText('toastNoPlaylist'));
}
};
function openDownloadModal() {
if (getSelectedLang() === null) {
showLanguageSelectionModal(() => {
openDownloadModal();
});
return;
}
if (activeModalBackdrop) {
activeModalBackdrop.remove();
}
const extractor = getExtractor();
const info = extractor();
const backdrop = document.createElement('div');
backdrop.className = 'ksmo-modal-backdrop';
const modal = document.createElement('div');
modal.className = 'ksmo-modal';
let alignModalHandler = null;
if (window.location.hostname.includes('tiktok.com')) {
backdrop.classList.add('ksmo-modal-tiktok');
modal.classList.add('ksmo-modal-tiktok');
const alignModal = () => {
const sb = document.getElementById('ksmo-tt-sidebar');
if (sb && modal) {
const r = sb.getBoundingClientRect();
modal.style.height = `${r.height}px`;
modal.style.top = `${r.top}px`;
// Tucked 5px behind the sidebar's left edge
modal.style.right = `${window.innerWidth - r.left - 5}px`;
modal.style.position = 'fixed';
modal.style.margin = '0';
}
};
alignModal();
alignModalHandler = alignModal;
window.addEventListener('resize', alignModal);
}
const header = document.createElement('div');
header.className = 'ksmo-modal-header';
const title = document.createElement('div');
title.className = 'ksmo-modal-title';
title.textContent = info ? info.title : (window.location.hostname.includes('spotify.com') ? getText('labelSpotify') : getText('title'));
const headerActions = document.createElement('div');
headerActions.className = 'ksmo-header-actions';
const settingsBtn = document.createElement('button');
settingsBtn.className = 'ksmo-modal-min';
settingsBtn.setAttribute('aria-label', 'Settings');
settingsBtn.appendChild(createSvgIcon('settings'));
settingsBtn.addEventListener('click', () => {
openDashboard();
});
const minBtn = document.createElement('button');
minBtn.className = 'ksmo-modal-min';
minBtn.setAttribute('aria-label', getText('ariaMinimize'));
minBtn.appendChild(createSvgIcon('minimize'));
const closeBtn = document.createElement('button');
closeBtn.className = 'ksmo-modal-close';
closeBtn.setAttribute('aria-label', getText('ariaClose'));
closeBtn.appendChild(createSvgIcon('close'));
headerActions.appendChild(settingsBtn);
headerActions.appendChild(minBtn);
headerActions.appendChild(closeBtn);
header.appendChild(title);
header.appendChild(headerActions);
modal.appendChild(header);
const progressContainer = document.createElement('div');
progressContainer.className = 'ksmo-progress-container';
const progressName = document.createElement('div');
progressName.className = 'ksmo-progress-name';
progressName.style.fontSize = '11px';
progressName.style.color = '#a29bfe';
progressName.style.marginBottom = '4px';
progressName.style.overflow = 'hidden';
progressName.style.textOverflow = 'ellipsis';
progressName.style.whiteSpace = 'nowrap';
const progressBar = document.createElement('div');
progressBar.className = 'ksmo-progress-bar';
const fill = document.createElement('div');
fill.className = 'ksmo-progress-fill';
progressBar.appendChild(fill);
const progressText = document.createElement('div');
progressText.className = 'ksmo-progress-text';
const percentText = document.createElement('span');
percentText.className = 'ksmo-progress-percent';
percentText.textContent = '0%';
const cancelBtn = document.createElement('span');
cancelBtn.className = 'ksmo-progress-cancel';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.textDecoration = 'underline';
cancelBtn.textContent = getText('cancel');
progressText.appendChild(percentText);
progressText.appendChild(cancelBtn);
progressContainer.appendChild(progressName);
progressContainer.appendChild(progressBar);
progressContainer.appendChild(progressText);
modal.appendChild(progressContainer);
cancelBtn.addEventListener('click', () => {
DownloadQueue.clear();
progressContainer.style.display = 'none';
showToast(getText('allCanceled'));
});
const list = document.createElement('div');
list.className = 'ksmo-quality-list';
if (info && info.formats && info.formats.length > 0) {
info.formats.forEach(f => {
renderQualityRow(f, info, progressContainer, fill, percentText, list);
});
} else if (window.location.hostname.includes('spotify.com')) {
const spotifyMessage = document.createElement('div');
spotifyMessage.style.textAlign = 'center';
spotifyMessage.style.padding = '20px 0';
spotifyMessage.style.color = '#a29bfe';
spotifyMessage.textContent = getText('spotifyRedirect');
list.appendChild(spotifyMessage);
} else {
const emptyMessage = document.createElement('div');
emptyMessage.style.textAlign = 'center';
emptyMessage.style.padding = '20px 0';
emptyMessage.style.color = '#94a3b8';
emptyMessage.textContent = getText('noFormats');
list.appendChild(emptyMessage);
}
modal.appendChild(list);
if (window.location.hostname.includes('youtube.com')) {
const urlParams = new URLSearchParams(window.location.search);
let videoId = urlParams.get('v');
if (!videoId && window.location.pathname.includes('/shorts/')) {
const match = window.location.pathname.match(/\/shorts\/([a-zA-Z0-9_-]+)/);
if (match) videoId = match[1];
}
if (!videoId && window.location.pathname.includes('/embed/')) {
const match = window.location.pathname.match(/\/embed\/([a-zA-Z0-9_-]+)/);
if (match) videoId = match[1];
}
if (videoId) {
fetchEmbedPlayerResponse(videoId, (data) => {
if (!data || !data.streamingData) {
const hasProtected = Array.from(list.querySelectorAll('.ksmo-quality-row')).some(row => {
const lbl = row.querySelector('.ksmo-quality-label');
return lbl && (lbl.textContent.includes('Signature Protected') || lbl.textContent.includes('Cipher Blocked'));
});
if (hasProtected) {
showToast(getText('toastYtBlocked'), 6000);
}
return;
}
const embedFormats = [];
const muxed = data.streamingData.formats || [];
const adaptive = data.streamingData.adaptiveFormats || [];
const parseFormat = (f, isMuxed) => {
let url = f.url;
let cipher = null;
if (!url && f.signatureCipher) {
cipher = f.signatureCipher;
}
if (url || cipher) {
const isVideo = f.mimeType?.includes('video');
embedFormats.push({
quality: f.qualityLabel || `${f.width}p`,
url: url || '',
cipher: cipher,
type: f.mimeType?.split(';')[0] || 'video/mp4',
label: cipher ? (isMuxed ? `Muxed [${utils.getCodec(f.mimeType)}] (${getText('deciphering')})` : (isVideo ? `Adaptive [${utils.getCodec(f.mimeType)}] (${getText('deciphering')})` : `Audio Only [${utils.getCodec(f.mimeType)}] (${getText('deciphering')})`)) : (isMuxed ? `Muxed [${utils.getCodec(f.mimeType)}]` : (isVideo ? `Adaptive [${utils.getCodec(f.mimeType)}] (${getText('adaptiveNoAudio')})` : `Audio Only [${utils.getCodec(f.mimeType)}]`))
});
}
};
muxed.forEach(f => parseFormat(f, true));
adaptive.forEach(f => parseFormat(f, false));
if (embedFormats.length > 0) {
const existingRows = list.querySelectorAll('.ksmo-quality-row');
existingRows.forEach(row => {
const lbl = row.querySelector('.ksmo-quality-label');
if (lbl && (lbl.textContent.includes('Signature Protected') || lbl.textContent.includes('Cipher Blocked') || lbl.textContent.includes(getText('cipherBlocked')))) {
row.remove();
}
});
embedFormats.forEach(f => {
renderQualityRow(f, info || { title: document.title }, progressContainer, fill, percentText, list);
});
} else {
const hasProtected = Array.from(list.querySelectorAll('.ksmo-quality-row')).some(row => {
const lbl = row.querySelector('.ksmo-quality-label');
return lbl && (lbl.textContent.includes('Signature Protected') || lbl.textContent.includes('Cipher Blocked') || lbl.textContent.includes(getText('cipherBlocked')));
});
if (hasProtected) {
showToast(getText('toastYtBlocked'), 6000);
}
}
});
}
}
const warningLabel = document.createElement('div');
warningLabel.className = 'ksmo-warning-label';
warningLabel.textContent = getText('externalWarning');
modal.appendChild(warningLabel);
const footer = document.createElement('div');
footer.className = 'ksmo-modal-footer';
const footerRow = document.createElement('div');
footerRow.className = 'ksmo-footer-row';
const serviceSelect = document.createElement('select');
serviceSelect.className = 'ksmo-select';
serviceSelect.setAttribute('aria-label', getText('ariaSelectService'));
const available = getAvailableFallbacks();
const saved = getSetting(getFallbackServiceSettingKey(), available[0]);
const defaultService = available.includes(saved) ? saved : available[0];
available.forEach(service => {
const opt = document.createElement('option');
opt.value = service;
opt.textContent = service.toUpperCase();
if (service === defaultService) {
opt.selected = true;
}
serviceSelect.appendChild(opt);
});
serviceSelect.addEventListener('change', () => {
setSetting(getFallbackServiceSettingKey(), serviceSelect.value);
});
const fallbackBtn = document.createElement('button');
fallbackBtn.className = 'ksmo-secondary-btn';
fallbackBtn.setAttribute('aria-label', getText('fallback'));
fallbackBtn.textContent = getText('fallback');
fallbackBtn.addEventListener('click', () => {
if (confirm(getText('toastDirectFailConfirm'))) {
let url = window.location.href;
if (info && info.formats && info.formats.length > 0) {
url = info.formats[0].pageUrl || url;
}
triggerFallbackRedirect(url);
}
});
const copyLinkBtn = document.createElement('button');
copyLinkBtn.className = 'ksmo-secondary-btn';
copyLinkBtn.setAttribute('aria-label', getText('copyLink'));
copyLinkBtn.textContent = getText('copyLink');
copyLinkBtn.addEventListener('click', () => {
let link = info && info.formats && info.formats.length > 0 ? info.formats[0].url : window.location.href;
if (window.location.hostname.includes('instagram.com')) {
link = window.location.href.split('?')[0];
} else if (window.location.hostname.includes('youtube.com')) {
const urlParams = new URLSearchParams(window.location.search);
const videoId = urlParams.get('v');
if (videoId) {
link = `https://www.youtube.com/watch?v=${videoId}`;
const t = urlParams.get('t');
if (t) link += `&t=${t}`;
} else {
link = window.location.href.split('?')[0];
}
}
navigator.clipboard.writeText(link).then(() => {
showToast(getText('toastCopySuccess'));
}).catch(() => {
showToast(getText('toastCopyFail'));
});
});
if (window.location.hostname.includes('tiktok.com')) {
const copyTikTokBtn = document.createElement('button');
copyTikTokBtn.className = 'ksmo-secondary-btn';
copyTikTokBtn.textContent = getText('copyTikTokLink');
copyTikTokBtn.addEventListener('click', () => {
const link = buildTikTokLink() || (() => {
const vid = getVideoIdFromURL();
const user = tiktokState.currentUsername || getUsernameFromArticle(getVisibleArticle()) || 'user';
return vid ? `https://www.tiktok.com/@${user.replace(/^@/, '')}/video/${vid}` : null;
})();
if (link) {
navigator.clipboard.writeText(link).then(() => {
showToast(getText('tiktokLinkCopied'));
}).catch(() => {
showToast(getText('toastCopyFail'));
});
} else {
showToast(getText('tiktokNoVideo'));
}
});
footerRow.appendChild(copyTikTokBtn);
}
const cobaltBtn = document.createElement('button');
cobaltBtn.className = 'ksmo-secondary-btn';
cobaltBtn.setAttribute('aria-label', getText('cobalt'));
cobaltBtn.textContent = getText('cobalt');
cobaltBtn.style.borderColor = '#6c5ce7';
cobaltBtn.style.color = '#a29bfe';
cobaltBtn.addEventListener('click', () => {
let url = window.location.href;
if (info && info.formats && info.formats.length > 0) {
const firstFormat = info.formats[0];
url = firstFormat.pageUrl || firstFormat.url || url;
}
if (!url || url.startsWith('blob:')) {
url = window.location.href;
}
if (window.location.hostname.includes('tiktok.com')) {
showTikTokCobaltConfirmation(url);
} else {
openCobaltUI(url);
}
});
footerRow.appendChild(serviceSelect);
footerRow.appendChild(fallbackBtn);
footerRow.appendChild(cobaltBtn);
footerRow.appendChild(copyLinkBtn);
if (window.location.hostname.includes('youtube.com') && window.location.search.includes('list=')) {
const playlistBtn = document.createElement('button');
playlistBtn.className = 'ksmo-secondary-btn';
playlistBtn.textContent = getText('exportPlaylist');
playlistBtn.addEventListener('click', exportPlaylist);
footerRow.appendChild(playlistBtn);
}
footer.appendChild(footerRow);
modal.appendChild(footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
activeModalBackdrop = backdrop;
setTimeout(() => backdrop.classList.add('show'), 10);
const dismissModal = () => {
backdrop.classList.remove('show');
if (window.location.hostname.includes('tiktok.com')) {
document.removeEventListener('click', handleOutsideClick, true);
if (alignModalHandler) {
window.removeEventListener('resize', alignModalHandler);
}
}
setTimeout(() => {
DownloadQueue.clear();
backdrop.remove();
activeModalBackdrop = null;
}, 300);
};
const handleOutsideClick = (e) => {
if (modal && !modal.contains(e.target) && !document.getElementById('ksmo-tt-sidebar')?.contains(e.target)) {
dismissModal();
}
};
minBtn.addEventListener('click', (e) => {
e.stopPropagation();
minBtn.textContent = '';
if (backdrop.classList.contains('minimized')) {
backdrop.classList.remove('minimized');
minBtn.appendChild(createSvgIcon('minimize'));
} else {
backdrop.classList.add('minimized');
minBtn.appendChild(createSvgIcon('expand'));
}
});
modal.addEventListener('click', (e) => {
if (backdrop.classList.contains('minimized') && !e.target.closest('.ksmo-modal-close') && !e.target.closest('.ksmo-modal-min') && !e.target.closest('.ksmo-progress-cancel')) {
backdrop.classList.remove('minimized');
minBtn.textContent = '';
minBtn.appendChild(createSvgIcon('minimize'));
}
});
closeBtn.addEventListener('click', dismissModal);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) dismissModal();
});
if (window.location.hostname.includes('tiktok.com')) {
setTimeout(() => {
document.addEventListener('click', handleOutsideClick, true);
}, 50);
}
}
function triggerFallbackRedirect(targetUrl) {
const available = getAvailableFallbacks();
const key = getFallbackServiceSettingKey();
const saved = getSetting(key, available[0]);
const service = available.includes(saved) ? saved : available[0];
const resolver = FALLBACK_SERVICES[service] || FALLBACK_SERVICES[available[0]];
const finalUrl = resolver(targetUrl);
if (typeof GM_openInTab !== 'undefined') {
GM_openInTab(finalUrl, { active: true });
} else {
window.open(finalUrl, '_blank');
}
}
let visited = new WeakSet();
let isScanning = false;
function scanAndAttach(targetNode = document.body) {
if (isScanning) return;
isScanning = true;
let foundVideo = false;
try {
const nodesToProcess = Array.isArray(targetNode) ? targetNode : [targetNode];
const queue = [...nodesToProcess];
while (queue.length > 0) {
const node = queue.shift();
if (!node || visited.has(node)) continue;
visited.add(node);
if (typeof node.querySelectorAll === 'function') {
const videos = node.querySelectorAll('video');
videos.forEach(video => {
const area = video.offsetWidth * video.offsetHeight;
if ((video.offsetWidth > 50 && video.offsetHeight > 50) || video.readyState > 0 || area > 2500) {
foundVideo = true;
if (!window.location.hostname.includes('youtube.com')) {
attachTriggerButton(video);
}
}
});
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {
const el = walker.currentNode;
if (el.shadowRoot && !visited.has(el.shadowRoot)) {
queue.push(el.shadowRoot);
}
}
}
}
if (window.location.hostname.includes('youtube.com')) {
injectYouTubeControlButton();
nodesToProcess.forEach(n => {
if (typeof n.querySelectorAll === 'function') {
YOUTUBE_PLAYER_SELECTORS.forEach(selector => {
try {
const videos = n.querySelectorAll(selector);
if (videos.length > 0) foundVideo = true;
} catch (e) { }
});
}
});
}
} finally {
isScanning = false;
}
if (KNOWN_VIDEO_SITES.some(s => window.location.hostname.includes(s))) {
foundVideo = true;
}
const fab = document.getElementById('ksmo-floating-dl');
if (fab) {
if (foundVideo) {
fab.classList.add('visible');
} else {
fab.classList.remove('visible');
}
}
if (foundVideo && getUIMode() === 'sidebar') {
triggerSidebarUpdate();
}
}
let pendingNodes = [];
let scanTimeout = null;
const scanObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
pendingNodes.push(node);
}
});
});
if (pendingNodes.length > 0) {
clearTimeout(scanTimeout);
scanTimeout = setTimeout(() => {
scanAndAttach(pendingNodes);
pendingNodes = [];
}, 100);
}
});
let idleScanInterval = null;
let visitedResetInterval = null;
const createFloatingButton = () => {
if (window.top !== window.self) return;
if (window.location.hostname.includes('youtube.com')) return;
if (document.getElementById('ksmo-floating-dl')) return;
if (getSetting(SETTINGS_KEYS.showButton, true) === false) return;
const fab = document.createElement('div');
fab.id = 'ksmo-floating-dl';
fab.className = 'ksmo-floating-dl';
fab.setAttribute('aria-label', getText('floatingButtonLabel'));
fab.title = getText('nativeYtDlTitle');
fab.appendChild(createSvgIcon('download', { width: '20', height: '20' }));
fab.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (getUIMode() === 'sidebar') {
toggleSidebar();
} else {
openDownloadModal();
}
});
document.body.appendChild(fab);
if (KNOWN_VIDEO_SITES.some(s => window.location.hostname.includes(s))) {
fab.classList.add('visible');
}
};
const incrementDownloadStats = (bytes = 0) => {
try {
const total = (parseInt(localStorage.getItem('omnidown_stat_total') || '0', 10) || 0) + 1;
localStorage.setItem('omnidown_stat_total', total.toString());
const today = (parseInt(localStorage.getItem('omnidown_stat_today') || '0', 10) || 0) + 1;
localStorage.setItem('omnidown_stat_today', today.toString());
const savedBytes = (parseInt(localStorage.getItem('omnidown_stat_bytes') || '0', 10) || 0) + bytes;
localStorage.setItem('omnidown_stat_bytes', savedBytes.toString());
} catch (e) { }
};
const initApp = () => {
applyVisuals();
const DASHBOARD_SHOWN_KEY = 'omnidown_dashboard_shown';
if (!localStorage.getItem(DASHBOARD_SHOWN_KEY)) {
showToast(getText('firstRunToast') || '⚙️ OmniDown: Press Ctrl + Alt + Shift + D to configure settings', 10000);
localStorage.setItem(DASHBOARD_SHOWN_KEY, 'true');
}
if (getUIMode() === 'sidebar') {
createSidebarMode();
}
// Reset daily counter if new day
try {
const lastDate = localStorage.getItem('omnidown_stat_date');
const today = new Date().toDateString();
if (lastDate !== today) {
localStorage.setItem('omnidown_stat_today', '0');
localStorage.setItem('omnidown_stat_date', today);
}
} catch (e) { }
// On TikTok FYP, only the TikTok sidebar is the UI
// Don't create floating button or general popup scanner
if (isTikTokFYP()) {
initTikTokForYouSupport();
return;
}
// For other TikTok pages (not FYP), still init TikTok support
if (window.location.hostname.includes('tiktok.com')) {
initTikTokForYouSupport();
}
createFloatingButton();
scanAndAttach(document.body);
scanObserver.observe(document.body, { childList: true, subtree: true });
if (idleScanInterval) clearInterval(idleScanInterval);
idleScanInterval = setInterval(() => {
if (window.requestIdleCallback) {
window.requestIdleCallback(() => scanAndAttach(document.body));
} else {
scanAndAttach(document.body);
}
}, 300000);
if (visitedResetInterval) clearInterval(visitedResetInterval);
visitedResetInterval = setInterval(() => {
visited = new WeakSet();
}, 300000);
// YouTube SPA navigation support
if (window.location.hostname.includes('youtube.com')) {
document.addEventListener('yt-navigate-start', () => {
visited = new WeakSet(); // Reset BEFORE DOM changes
if (typeof baseJsFetcher !== 'undefined') {
baseJsFetcher.cachedCode = null;
baseJsFetcher.cachedUrl = null;
}
});
document.addEventListener('yt-navigate-finish', () => {
injectYouTubeControlButton();
scanAndAttach(document.body);
});
}
};
if (!document.body) {
new MutationObserver((_, obs) => {
if (document.body) {
initApp();
obs.disconnect();
}
}).observe(document.documentElement, { childList: true });
} else {
initApp();
}
window.addEventListener('keydown', (e) => {
if (getSetting(SETTINGS_KEYS.enableHotkey, true) === false) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
return;
}
const isD = e.key === 'd' || e.key === 'D' || e.keyCode === 68;
if (e.ctrlKey && e.altKey && e.shiftKey && isD) {
e.preventDefault();
e.stopPropagation();
const video = findAnyVideo();
if (!video && !window.location.hostname.includes('spotify.com')) {
showToast(getText('toastNoVideo'));
return;
}
if (getUIMode() === 'sidebar') {
toggleSidebar();
} else {
openDownloadModal();
}
}
if (e.key === 'Escape') {
if (activeDashboard) {
e.preventDefault();
e.stopPropagation();
closeDashboard();
} else if (activeSidebar && activeSidebar.classList.contains('sidebar-open')) {
e.preventDefault();
e.stopPropagation();
toggleSidebar();
} else if (activeModalBackdrop) {
e.preventDefault();
e.stopPropagation();
activeModalBackdrop.classList.remove('show');
setTimeout(() => {
DownloadQueue.clear();
activeModalBackdrop?.remove();
activeModalBackdrop = null;
}, 300);
}
}
}, true);
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand(getText('menuDashboard') || '⚙️ Open Dashboard', () => {
openDashboard();
});
GM_registerMenuCommand(getText('menuOpenMenu'), () => {
openDownloadModal();
});
GM_registerMenuCommand(getText('menuDownloadCobalt'), () => {
let url = window.location.href;
if (window.location.hostname.includes('tiktok.com')) {
const activeVideo = getActiveVideoElement();
let videoId = null;
if (activeVideo) {
let parent = activeVideo.parentElement;
while (parent && parent !== document.body) {
const links = parent.querySelectorAll('a[href*="/video/"]');
for (const link of links) {
const match = link.href.match(/\/video\/(\d+)/);
if (match) {
videoId = match[1];
break;
}
}
if (videoId) break;
parent = parent.parentElement;
}
}
if (!videoId) {
const match = window.location.href.match(/\/video\/(\d+)/);
if (match) videoId = match[1];
}
if (videoId) {
url = `https://www.tiktok.com/video/${videoId}`;
}
showTikTokCobaltConfirmation(url);
} else {
openCobaltUI(url);
}
});
GM_registerMenuCommand('🌐 Change Language / Alterar Idioma / 更改语言', () => {
showLanguageSelectionModal(() => {
showToast(getText('toastReloadPage'));
});
});
GM_registerMenuCommand(getText('menuSetCobaltUrl'), () => {
const current = getCobaltApiUrl();
const newApiUrl = prompt(getText('promptSetCobaltUrl'), current);
if (newApiUrl !== null) {
const finalUrl = newApiUrl.trim() || 'cobalt.tools';
setSetting(SETTINGS_KEYS.cobaltApiUrl, finalUrl);
showToast(getText('toastCobaltApiUrl').replace('{url}', finalUrl));
}
});
GM_registerMenuCommand(getText('menuSetProxy'), () => {
const current = getSetting(SETTINGS_KEYS.customProxy, '');
const proxy = prompt(getText('promptSetProxy'), current);
if (proxy !== null) {
setSetting(SETTINGS_KEYS.customProxy, proxy.trim());
showToast(proxy.trim() ? getText('toastProxyConfigured') : getText('toastProxyCleared'));
}
});
GM_registerMenuCommand(getText('menuToggleBtn'), () => {
const current = getSetting(SETTINGS_KEYS.showButton, true);
setSetting(SETTINGS_KEYS.showButton, !current);
showToast(`${getText('menuToggleBtn')}: ${!current ? getText('toastSettingEnabled') : getText('toastSettingDisabled')}. ${getText('toastReloadPage')}`);
});
GM_registerMenuCommand(getText('menuToggleFallback'), () => {
const current = getSetting(SETTINGS_KEYS.autoFallback, false);
setSetting(SETTINGS_KEYS.autoFallback, !current);
showToast(`${getText('menuToggleFallback')}: ${!current ? getText('toastSettingEnabled') : getText('toastSettingDisabled')}.`);
});
GM_registerMenuCommand(getText('menuToggleHotkey'), () => {
const current = getSetting(SETTINGS_KEYS.enableHotkey, true);
setSetting(SETTINGS_KEYS.enableHotkey, !current);
showToast(`${getText('menuToggleHotkey')}: ${!current ? getText('toastSettingEnabled') : getText('toastSettingDisabled')}.`);
});
GM_registerMenuCommand(getText('menuToggleUI'), () => {
toggleUIMode();
if (getUIMode() === 'sidebar') {
createSidebarMode();
} else {
if (activeSidebar) {
activeSidebar.remove();
activeSidebar = null;
}
if (activeSidebarOverlay) {
activeSidebarOverlay.remove();
activeSidebarOverlay = null;
}
}
});
}
window.addEventListener('beforeunload', () => {
scanObserver.disconnect();
if (idleScanInterval) clearInterval(idleScanInterval);
if (visitedResetInterval) clearInterval(visitedResetInterval);
visited = new WeakSet();
pendingNodes = [];
pendingSizes.clear();
toastQueue.length = 0;
if (activeToast) { activeToast.remove(); activeToast = null; }
clearTimeout(toastTimeout);
clearTimeout(scanTimeout);
});
/* ────────────────────────────────────────────────────────────────
* DEV SANITY CHECKS — Set the flag to true to run.
*
* Manual testing instructions for fixed bugs:
* Bug #1 (iframe guard): Open a page with YouTube embed ads → script should NOT run in ad iframes.
* Bug #4 (DownloadManager): Start a download, cancel mid-progress → should cancel cleanly.
* Bug #6 (toast queue): Trigger 3+ toasts rapidly → no duplicates, correct order.
* Bug #7 (YouTube SPA): Navigate between 3 YouTube videos → download button appears on ALL.
* Bug #10 (YT control btn): Same as #7 → player control button persists across navigations.
* ──────────────────────────────────────────────────────────────── */
if (false) {
console.assert(formatBytes(0) === '', 'formatBytes(0) should be empty');
console.assert(formatBytes(1024) === '1 KB', 'formatBytes(1024)');
console.assert(formatBytes(1536) === '1.5 KB', 'formatBytes(1536)');
console.assert(formatBytes(1048576) === '1 MB', 'formatBytes(1MB)');
console.assert(utils.getCodec('video/webm; codecs="vp9"') === 'WebM', 'getCodec webm');
console.assert(utils.getCodec('video/mp4; codecs="avc1"') === 'MP4', 'getCodec mp4');
console.assert(utils.getCodec('audio/ogg') === 'Ogg', 'getCodec ogg');
console.assert(utils.getCodec('') === 'MP4', 'getCodec empty fallback');
console.assert(convertXmlToSrt('') === '', 'convertXmlToSrt empty returns empty');
console.assert(extractJsonFromHtml('var x = {"a":1};', 'x') !== null, 'extractJson should find x');
console.assert(extractJsonFromHtml('nothing here', 'x') === null, 'extractJson miss returns null');
console.log('✅ OmniDown sanity checks passed');
}
})();