NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Twitter排行榜:TikTok版 - Safe Current
// @namespace local.redfoxy.twitter-ranking-tiktok-safe
// @version 5.3.0-safe.current.11
// @description 私有仓库加载版:TikTok上下切换、预览5个、随机;安卓 Kiwi 强化触控手势、长按倍速、位置进度,优先解析 mp4 内播并保留 PC 详情页兜底。
// @author local-safe-rewrite
// @license Apache-2.0
// @match *://twitter-ero-video-ranking.com/*
// @match *://www.twitter-ero-video-ranking.com/*
// @match *://m.twitter-ero-video-ranking.com/*
// @match *://truvaze.com/*
// @match *://www.truvaze.com/*
// @match *://m.truvaze.com/*
// @match *://x-ero-anime.com/*
// @match *://www.x-ero-anime.com/*
// @match *://m.x-ero-anime.com/*
// @run-at document-start
// @noframes
// @grant none
// ==/UserScript==
(() => {
'use strict';
try {
if (location.hostname === 'video.twimg.com') {
console.log('[rf-tiktok-safe-current] skip direct mp4 page');
return;
}
} catch {}
const ID = 'rf-tiktok-safe-current';
const MAX = 300;
const N = 5;
const RESOLVE_TIMEOUT = 12000;
const MOBILE_OPEN_SOURCE_FIRST = true;
const HOLD_SPEED = 2;
const HOLD_DELAY = 260;
const mp4s = new Set();
const pages = new Map();
const bad = new Set();
const st = {
items: [],
i: 0,
open: false,
pOpen: false,
off: 0,
busy: false,
wheel: 0,
ty: 0,
tx: 0,
moved: false,
source: 'dom',
touchAt: 0,
tapLockUntil: 0,
holdTimer: 0,
speedHold: false,
baseRate: 1
};
const U = (u, b = location.href) => { try { return new URL(String(u || '').trim(), b).href; } catch { return ''; } };
const host = u => { try { return new URL(u, location.href).hostname; } catch { return ''; } };
const isMp4 = u => host(u) === 'video.twimg.com' && /\.mp4(?:$|\?)/i.test(U(u));
const isPage = u => host(u) === location.hostname && /\/movie\//i.test(new URL(U(u)).pathname);
const isMobileEnv = () => /android|iphone|ipad|ipod|mobile|kiwi/i.test(navigator.userAgent || '');
const MOBILE_MODE = isMobileEnv();
const canUseIframeFallback = () => !isMobileEnv();
const txt = (s, n = 120) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
const esc = s => String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const uniq = a => [...new Set(a.filter(Boolean))];
function dec(s) { const t = document.createElement('textarea'); t.innerHTML = String(s || ''); return t.value; }
function ready(fn) { if (document.body) return fn(); const t = setInterval(() => { if (document.body) { clearInterval(t); fn(); } }, 80); }
function getMp4(s, base = location.href) {
const raw = dec(s).replace(/\\u0026/g, '&').replace(/&/g, '&');
const out = [];
const rs = [
/https?:\/\/video\.twimg\.com\/[^"' <>\n\r\\]+?\.mp4(?:\?[^"' <>\n\r\\]+)?/gi,
/https?:\\\/\\\/video\.twimg\.com\\\/[^"' <>\n\r]+?\.mp4(?:\?[^"' <>\n\r]+)?/gi
];
for (const re of rs) {
let m;
while ((m = re.exec(raw))) {
const u = U(m[0].replace(/\\\//g, '/').replace(/\\u0026/g, '&'), base);
if (isMp4(u)) out.push(u);
}
}
return uniq(out);
}
function getPages(s, base = location.href) {
const raw = dec(s);
const out = [];
const rs = [
/href=["']([^"']*\/movie\/[^"']+)["']/gi,
/(?:https?:\/\/[^"' <>\n\r]+)?\/[a-zA-Z-]+\/movie\/[A-Za-z0-9_-]+/g,
/(?:https?:\/\/[^"' <>\n\r]+)?\/movie\/[A-Za-z0-9_-]+/g
];
for (const re of rs) {
let m;
while ((m = re.exec(raw))) {
const u = U(m[1] || m[0], base);
if (isPage(u)) out.push(u);
}
}
return uniq(out);
}
function cap(s, src = 'text', base = location.href) {
let ch = false;
const foundMp4 = getMp4(s, base);
const foundPages = getPages(s, base);
for (const u of foundMp4) { if (!mp4s.has(u)) ch = true; mp4s.add(u); }
for (const u of foundPages) { if (!pages.has(u)) ch = true; pages.set(u, { url: u, title: 'Movie Page' }); }
if (ch) {
console.log(`[${ID}] captured from ${src}`, { mp4: mp4s.size, pages: pages.size });
try { scan(); } catch {}
btnText();
}
return { mp4s: foundMp4, pages: foundPages };
}
function injectPageHook() {
window.addEventListener('message', e => {
const d = e.data;
if (e.source !== window || !d || d.__RF_TIKTOK_HOOK__ !== ID) return;
let ch = false;
for (const u of d.mp4s || []) if (isMp4(u)) { if (!mp4s.has(u)) ch = true; mp4s.add(u); }
for (const u of d.pages || []) if (isPage(u)) { if (!pages.has(u)) ch = true; pages.set(u, { url: u, title: 'Movie Page' }); }
if (ch) { scan(); btnText(); }
});
const code = `(() => {
const ID=${JSON.stringify(ID)};
const U=(u,b=location.href)=>{try{return new URL(String(u||'').trim(),b).href}catch{return''}};
const host=u=>{try{return new URL(u,location.href).hostname}catch{return''}};
const isMp4=u=>host(u)==='video.twimg.com'&&/\\.mp4(?:$|\\?)/i.test(U(u));
const isPage=u=>host(u)===location.hostname&&/\\/movie\\//i.test(new URL(U(u)).pathname);
function getMp4(s){const raw=String(s||'').replace(/\\\\u0026/g,'&').replace(/&/g,'&'),out=[];for(const re of [/https?:\\/\\/video\\.twimg\\.com\\/[^"' <>\\n\\r\\\\]+?\\.mp4(?:\\?[^"' <>\\n\\r\\\\]+)?/gi,/https?:\\\\\\/\\\\\\/video\\.twimg\\.com\\\\\\/[^"' <>\\n\\r]+?\\.mp4(?:\\?[^"' <>\\n\\r]+)?/gi]){let m;while((m=re.exec(raw))){const u=U(m[0].replace(/\\\\\//g,'/').replace(/\\\\u0026/g,'&'));if(isMp4(u))out.push(u)}}return[...new Set(out)]}
function getPages(s){const raw=String(s||''),out=[];for(const re of [/href=["']([^"']*\\/movie\\/[^"']+)["']/gi,/(?:https?:\\/\\/[^"' <>\\n\\r]+)?\\/[a-zA-Z-]+\\/movie\\/[A-Za-z0-9_-]+/g,/(?:https?:\\/\\/[^"' <>\\n\\r]+)?\\/movie\\/[A-Za-z0-9_-]+/g]){let m;while((m=re.exec(raw))){const u=U(m[1]||m[0]);if(isPage(u))out.push(u)}}return[...new Set(out)]}
function post(s,src){const mp4s=getMp4(s),pages=getPages(s);if(mp4s.length||pages.length)window.postMessage({__RF_TIKTOK_HOOK__:ID,src,mp4s,pages},location.origin)}
const rf=window.fetch;if(typeof rf==='function'&&!rf.__rfcurrent){window.fetch=async function(...a){const r=await rf.apply(this,a);try{const c=r.clone(),ct=c.headers.get('content-type')||'';if(/json|text|html|javascript/i.test(ct))c.text().then(t=>post(t,'fetch')).catch(()=>{})}catch{}return r};window.fetch.__rfcurrent=1}
const X=window.XMLHttpRequest;if(X&&X.prototype&&!X.prototype.__rfcurrent){const o=X.prototype.open,s=X.prototype.send;X.prototype.open=function(...a){this.__u=a[1];return o.apply(this,a)};X.prototype.send=function(...a){this.addEventListener('load',function(){try{const ct=this.getResponseHeader('content-type')||'';if(/json|text|html|javascript/i.test(ct)&&typeof this.responseText==='string')post(this.responseText,'xhr')}catch{}});return s.apply(this,a)};X.prototype.__rfcurrent=1}
try{new PerformanceObserver(l=>{const mp4s=[],pages=[];for(const e of l.getEntries()){if(isMp4(e.name))mp4s.push(e.name);if(isPage(e.name))pages.push(e.name)}if(mp4s.length||pages.length)window.postMessage({__RF_TIKTOK_HOOK__:ID,src:'perf',mp4s,pages},location.origin)}).observe({entryTypes:['resource']})}catch{}
try{post(document.documentElement&&document.documentElement.innerHTML,'dom')}catch{}
})();`;
const run = () => {
try {
const s = document.createElement('script');
s.textContent = code;
(document.documentElement || document.head || document.body).appendChild(s);
s.remove();
} catch {}
};
if (document.documentElement) run();
else document.addEventListener('DOMContentLoaded', run, { once: true });
}
function sandboxHook() {
const f = window.fetch;
if (typeof f === 'function' && !f.__rfcurrentSandbox) {
window.fetch = async function (...a) {
const r = await f.apply(this, a);
try {
const c = r.clone();
const ct = c.headers.get('content-type') || '';
if (/json|text|html|javascript/i.test(ct)) c.text().then(t => cap(t, 'fetch')).catch(() => {});
} catch {}
return r;
};
window.fetch.__rfcurrentSandbox = 1;
}
try {
new PerformanceObserver(l => {
let ch = false;
for (const e of l.getEntries()) {
if (isMp4(e.name)) { if (!mp4s.has(e.name)) ch = true; mp4s.add(e.name); }
if (isPage(e.name)) { if (!pages.has(e.name)) ch = true; pages.set(e.name, { url: e.name, title: 'Movie Page' }); }
}
if (ch) { scan(); btnText(); }
}).observe({ entryTypes: ['resource'] });
} catch {}
}
function css() {
if (document.getElementById(`${ID}-style`)) return;
const s = document.createElement('style');
s.id = `${ID}-style`;
s.textContent = `
#${ID}-btn{position:fixed!important;right:14px!important;bottom:16px!important;z-index:2147483647!important;height:42px!important;min-width:116px!important;padding:0 14px!important;border:0!important;border-radius:999px!important;background:rgba(0,0,0,.88)!important;color:#fff!important;font:700 14px system-ui!important;box-shadow:0 8px 24px rgba(0,0,0,.36)!important}
#${ID}-ov{display:none!important;position:fixed!important;inset:0!important;z-index:2147483646!important;background:#000!important;color:#fff!important;font-family:system-ui!important;overflow:hidden!important}
#${ID}-ov.open{display:block!important}
#${ID}-video{position:absolute!important;inset:0!important;width:100vw!important;height:100vh!important;object-fit:contain!important;background:#000!important;z-index:1!important}
#${ID}-fr{position:absolute!important;inset:0!important;width:100vw!important;height:100vh!important;border:0!important;background:#000!important;z-index:2!important;display:none!important}
#${ID}-gesture{position:absolute!important;inset:0!important;z-index:6!important;background:transparent!important;touch-action:none!important}
#${ID}-top{z-index:20!important;position:absolute!important;left:0!important;right:0!important;top:0!important;display:grid!important;grid-template-columns:44px 1fr auto!important;gap:8px!important;align-items:center!important;padding:9px 10px 48px!important;background:linear-gradient(to bottom,rgba(0,0,0,.72),rgba(0,0,0,0))!important;pointer-events:none!important}
#${ID}-top button,#${ID}-side button,#${ID}-panel button{pointer-events:auto!important;border:0!important;border-radius:999px!important;background:rgba(255,255,255,.18)!important;color:#fff!important}
#${ID}-close{width:38px!important;height:38px!important;font-size:26px!important}
#${ID}-title{overflow:hidden!important;white-space:nowrap!important;text-overflow:ellipsis!important;font-size:14px!important;text-shadow:0 1px 2px #000!important}
#${ID}-count{font-size:13px!important;color:rgba(255,255,255,.82)!important}
#${ID}-side{z-index:30!important;position:absolute!important;right:10px!important;top:50%!important;transform:translateY(-50%)!important;display:flex!important;flex-direction:column!important;gap:8px!important}
#${ID}-side button{width:40px!important;height:40px!important;font-size:13px!important}
#${ID}-msg{z-index:50!important;display:none!important;position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;max-width:min(88vw,680px)!important;padding:14px 18px!important;border-radius:14px!important;background:rgba(0,0,0,.78)!important;color:#fff!important;font-size:14px!important;line-height:1.6!important;text-align:center!important;white-space:pre-line!important}
#${ID}-msg.show{display:block!important}
#${ID}-prog{z-index:35!important;position:absolute!important;left:12px!important;right:12px!important;bottom:12px!important;height:4px!important;border-radius:999px!important;background:rgba(255,255,255,.18)!important;overflow:hidden!important}
#${ID}-prog-fill{height:100%!important;width:0!important;background:linear-gradient(90deg,#fff,#9fe8ff)!important;transition:width .18s ease!important}
#${ID}-panel{z-index:40!important;pointer-events:auto!important;display:none!important;position:absolute!important;left:50%!important;bottom:52px!important;transform:translateX(-50%)!important;width:min(760px,calc(100vw - 24px))!important;max-height:70vh!important;background:rgba(20,20,20,.94)!important;border:1px solid rgba(255,255,255,.14)!important;border-radius:16px!important;overflow:hidden!important;box-shadow:0 18px 60px rgba(0,0,0,.55)!important}
#${ID}-panel.open{display:block!important}
#${ID}-ph{display:flex!important;gap:6px!important;align-items:center!important;justify-content:space-between!important;padding:10px!important;border-bottom:1px solid rgba(255,255,255,.12)!important}
#${ID}-pa{display:flex!important;gap:6px!important;flex-wrap:wrap!important;justify-content:flex-end!important}
#${ID}-pa button{height:30px!important;padding:0 9px!important;font-size:12px!important}
#${ID}-list{display:grid!important;grid-template-columns:repeat(5,1fr)!important;gap:8px!important;padding:10px!important}
.${ID}-card{cursor:pointer!important;border:1px solid rgba(255,255,255,.12)!important;border-radius:12px!important;background:rgba(255,255,255,.06)!important;overflow:hidden!important}
.${ID}-card.active{border-color:#fff!important;background:rgba(255,255,255,.16)!important}
.${ID}-thumb{width:100%!important;aspect-ratio:9/12!important;object-fit:cover!important;display:flex!important;align-items:center!important;justify-content:center!important;background:rgba(255,255,255,.08)!important;color:rgba(255,255,255,.78)!important}
.${ID}-t{padding:7px 8px 3px!important;font-size:12px!important;color:#eee!important;overflow:hidden!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important}
.${ID}-m{padding:0 8px 8px!important;font-size:11px!important;color:rgba(255,255,255,.56)!important}
#${ID}-ov.mobile #${ID}-video{object-fit:contain!important}
#${ID}-ov.mobile #${ID}-gesture{display:block!important}
#${ID}-ov:not(.mobile) #${ID}-gesture{pointer-events:none!important}
@media(max-width:720px){#${ID}-list{grid-template-columns:1fr!important;max-height:54vh!important;overflow:auto!important}.${ID}-card{display:grid!important;grid-template-columns:70px 1fr!important}.${ID}-thumb{aspect-ratio:1/1!important}#${ID}-panel{bottom:48px!important}#${ID}-side{right:7px!important}}
`;
(document.head || document.documentElement).appendChild(s);
}
function ensureBtn() {
ready(() => {
css();
if (!document.getElementById(`${ID}-btn`)) {
const b = document.createElement('button');
b.id = `${ID}-btn`;
b.type = 'button';
b.textContent = 'TikTok播放';
b.onclick = openPlayer;
document.body.appendChild(b);
}
btnText();
});
}
function btnText() {
const b = document.getElementById(`${ID}-btn`);
if (b) b.textContent = `TikTok播放${Math.max(st.items.length, mp4s.size, pages.size) ? ' ' + Math.max(st.items.length, mp4s.size, pages.size) : ''}`;
}
function box(el) {
return el.closest?.('article') || el.closest?.('[class*="card" i]') || el.closest?.('[class*="item" i]') || el.closest?.('[class*="video" i]') || el.closest?.('li') || el.closest?.('div') || el;
}
function poster(el) {
const img = box(el)?.querySelector?.('img[src],img[data-src],img[data-original],img[data-lazy-src]');
return U(img?.getAttribute('src') || img?.getAttribute('data-src') || img?.getAttribute('data-original') || img?.getAttribute('data-lazy-src') || '');
}
function ignore(a) {
const href = U(a.getAttribute('href'));
if (!href) return true;
const u = new URL(href);
const p = u.pathname.toLowerCase();
const t = txt(a.textContent).toLowerCase();
if (u.hostname !== location.hostname && u.hostname !== 'video.twimg.com') return true;
if (['/', '/zh-cn', '/zh-cn/', '/en', '/en/', '/ja', '/ja/'].includes(p)) return true;
if (['/tag', '/tags', '/search', '/filter', '/language', '/contact', '/privacy', '/terms', '/dmca'].some(x => p.includes(x))) return true;
return ['每日', '每周', '每月', '所有时间', '筛选', 'language', 'telegram', '保存视频'].some(x => t.includes(x.toLowerCase()));
}
function add(arr, seen, it) {
const x = { page: it.page || '', url: it.url || '', poster: it.poster || '', title: txt(it.title || '视频'), cand: [], ci: 0 };
const pageOk = !!x.page && isPage(x.page);
const urlOk = !!x.url && isMp4(x.url);
if (!pageOk && !urlOk) return;
const id = x.page || x.url || x.poster || x.title;
if (!id || seen.has(id)) return;
seen.add(id);
arr.push(x);
}
function scan() {
const arr = [];
const seen = new Set();
try { cap(document.documentElement.innerHTML, 'dom'); } catch {}
document.querySelectorAll('script').forEach(s => cap(s.textContent || '', 'script'));
document.querySelectorAll('a[href]').forEach(a => {
const href = U(a.getAttribute('href'));
if (!href || ignore(a)) return;
const b = box(a);
if (isMp4(href)) add(arr, seen, { url: href, page: href, poster: poster(a), title: txt(b?.innerText || a.textContent) });
else if (isPage(href)) add(arr, seen, { page: href, poster: poster(a), title: txt(b?.innerText || a.textContent) });
});
for (const [u, m] of pages) add(arr, seen, { page: u, title: m.title || 'Movie Page' });
st.items = arr.slice(0, MAX);
st.i = Math.min(st.i, Math.max(0, st.items.length - 1));
st.source = 'dom';
btnText();
if (st.pOpen) renderPreview('current');
return st.items;
}
function currentLangPrefix() {
const m = location.pathname.match(/^\/([a-z]{2}(?:-[A-Z]{2})?)\//);
return m ? `/${m[1]}` : '/zh-CN';
}
function moviePageFromCd(cd) {
if (!cd) return '';
return `${location.origin}${currentLangPrefix()}/movie/${encodeURIComponent(String(cd))}`;
}
function normalizeApiMediaPayload(json) {
const raw =
Array.isArray(json) ? json :
Array.isArray(json?.data) ? json.data :
Array.isArray(json?.media) ? json.media :
Array.isArray(json?.items) ? json.items :
Array.isArray(json?.results) ? json.results :
[];
return raw.map(x => {
const mp4 = x.url || x.video_url || x.videoUrl || x.mp4 || '';
const cd = x.url_cd || x.code || x.id || '';
const thumb = x.thumbnail || x.thumb || x.poster || '';
const t = x.time || x.duration || '';
const fav = x.favorite || x.fav || '';
const title = `${t ? `${t}s` : ''}${fav ? ` ❤${fav}` : ''}${cd ? ` ${cd}` : ''}`.trim() || x.title || x.created_at || cd || '视频';
return {
url: U(mp4),
page: moviePageFromCd(cd),
poster: U(thumb),
title: txt(String(title)),
cand: [],
ci: 0
};
}).filter(it => isMp4(it.url) && isPage(it.page));
}
async function loadItemsFromApi() {
const candidates = [
`${location.origin}/api/media?range=&page=1&per_page=50&category=&ids=&isAnimeOnly=0&sort=favorite`,
`${location.origin}/api/media?sort=favorite`,
`${location.origin}/api/media?sort=all`
];
for (const api of candidates) {
try {
const r = await fetch(api, { credentials: 'include' });
if (!r.ok) continue;
const json = await r.json();
const items = normalizeApiMediaPayload(json);
if (items.length) {
console.log(`[${ID}] api media loaded`, { api, count: items.length });
return items.slice(0, MAX);
}
} catch (e) {
console.warn(`[${ID}] api media failed`, api, e);
}
}
return [];
}
async function fetchText(u) {
const c = new AbortController();
const tm = setTimeout(() => c.abort(), RESOLVE_TIMEOUT);
try {
const r = await fetch(u, { credentials: 'include', signal: c.signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.text();
} finally {
clearTimeout(tm);
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function resolvePageByHiddenIframe(pageUrl) {
pageUrl = U(pageUrl);
if (!pageUrl || !isPage(pageUrl) || host(pageUrl) !== location.hostname) return [];
const iframe = document.createElement('iframe');
iframe.style.cssText = [
'position:fixed',
'left:-99999px',
'top:-99999px',
'width:1px',
'height:1px',
'opacity:0',
'pointer-events:none',
'z-index:-1'
].join(';');
const found = new Set();
function collectFromDoc(doc, base) {
try {
const html = doc?.documentElement?.innerHTML || '';
for (const u of getMp4(html, base)) {
if (isMp4(u)) found.add(u);
}
doc?.querySelectorAll?.('video,source,a,[src],[href],[data-url],[data-video],[data-video-url],[data-video-src],[data-mp4]').forEach(el => {
const attrs = ['src', 'href', 'data-url', 'data-video', 'data-video-url', 'data-video-src', 'data-mp4'];
for (const attr of attrs) {
const val = el.getAttribute?.(attr);
const u = U(val, base);
if (isMp4(u)) found.add(u);
}
});
} catch {}
}
try {
const loaded = new Promise(resolve => {
iframe.onload = () => resolve();
setTimeout(resolve, RESOLVE_TIMEOUT);
});
iframe.src = pageUrl;
document.body.appendChild(iframe);
await loaded;
for (const delay of [300, 1000, 2200, 4000]) {
await sleep(delay);
try {
const cw = iframe.contentWindow;
const doc = iframe.contentDocument;
const current = cw?.location?.href || '';
if (isMp4(current)) found.add(current);
collectFromDoc(doc, pageUrl);
} catch {}
if (found.size) break;
}
} finally {
try { iframe.remove(); } catch {}
}
return [...found];
}
async function candidates(it) {
const out = [];
if (it.url && isMp4(it.url) && !bad.has(it.url)) {
out.push(it.url);
}
if (!out.length && it.page && host(it.page) === location.hostname) {
try {
const html = await fetchText(it.page);
const r = cap(html, 'resolve-fetch', it.page);
out.push(...r.mp4s.filter(u => isMp4(u) && !bad.has(u)));
} catch (e) {
console.warn(`[${ID}] resolve-fetch failed`, e);
}
}
if (!out.length && it.page && host(it.page) === location.hostname) {
try {
const r = await resolvePageByHiddenIframe(it.page);
out.push(...r.filter(u => isMp4(u) && !bad.has(u)));
} catch (e) {
console.warn(`[${ID}] resolve-iframe failed`, e);
}
}
return uniq(out);
}
async function refreshCurrentItemFromApi(it) {
if (!it?.page || !isPage(it.page)) return [];
const m = it.page.match(/\/movie\/([^/?#]+)/i);
const cd = m ? decodeURIComponent(m[1]) : '';
if (!cd) return [];
try {
const items = await loadItemsFromApi();
const hit = items.find(x => (x.page || '').endsWith(`/movie/${encodeURIComponent(cd)}`) || (x.page || '').endsWith(`/movie/${cd}`));
return hit?.url && isMp4(hit.url) ? [hit.url] : [];
} catch {
return [];
}
}
function createUI() {
if (document.getElementById(`${ID}-ov`)) return;
const o = document.createElement('div');
o.id = `${ID}-ov`;
if (MOBILE_MODE) o.classList.add('mobile');
o.innerHTML = `<video id="${ID}-video" controls playsinline webkit-playsinline preload="metadata"></video><iframe id="${ID}-fr" referrerpolicy="no-referrer" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe><div id="${ID}-gesture"></div><div id="${ID}-top"><button id="${ID}-close">×</button><div id="${ID}-title">TikTok播放</div><div id="${ID}-count">0/0</div></div><div id="${ID}-side"><button id="${ID}-prev">↑</button><button id="${ID}-next">↓</button><button id="${ID}-pre">预</button><button id="${ID}-rand">随</button><button id="${ID}-swap">换</button><button id="${ID}-src">外开</button><button id="${ID}-sc">扫</button></div><div id="${ID}-prog"><div id="${ID}-prog-fill"></div></div><div id="${ID}-panel"><div id="${ID}-ph"><div id="${ID}-cap">预览</div><div id="${ID}-pa"><button id="${ID}-p0">当前5</button><button id="${ID}-p1">上5</button><button id="${ID}-p2">下5</button><button id="${ID}-p3">随机5</button><button id="${ID}-px">关</button></div></div><div id="${ID}-list"></div></div><div id="${ID}-msg"></div>`;
document.body.appendChild(o);
document.getElementById(`${ID}-close`).onclick = closePlayer;
document.getElementById(`${ID}-prev`).onclick = () => go(-1);
document.getElementById(`${ID}-next`).onclick = () => go(1);
document.getElementById(`${ID}-pre`).onclick = () => togglePreview();
document.getElementById(`${ID}-rand`).onclick = rand;
document.getElementById(`${ID}-swap`).onclick = swap;
document.getElementById(`${ID}-src`).onclick = openSrc;
document.getElementById(`${ID}-sc`).onclick = () => { scan(); msg(`已扫描 ${st.items.length} 条`, 1500); };
document.getElementById(`${ID}-p0`).onclick = () => renderPreview('current');
document.getElementById(`${ID}-p1`).onclick = () => renderPreview('prev');
document.getElementById(`${ID}-p2`).onclick = () => renderPreview('next');
document.getElementById(`${ID}-p3`).onclick = () => renderPreview('random');
document.getElementById(`${ID}-px`).onclick = hidePreview;
o.addEventListener('wheel', onWheel, { passive: false });
const g = document.getElementById(`${ID}-gesture`);
g?.addEventListener('touchstart', onTouchStart, { passive: false });
g?.addEventListener('touchmove', onTouchMove, { passive: false });
g?.addEventListener('touchend', onTouchEnd, { passive: false });
g?.addEventListener('touchcancel', endHold, { passive: true });
g?.addEventListener('click', onGestureTap, { passive: false });
document.addEventListener('keydown', onKey);
}
function videoEl() {
return document.getElementById(`${ID}-video`);
}
function frameEl() {
return document.getElementById(`${ID}-fr`);
}
function hideEmbeddedPage() {
const fr = frameEl();
if (!fr) return;
fr.style.display = 'none';
fr.removeAttribute('src');
}
function showEmbeddedPage(pageUrl) {
const page = U(pageUrl);
if (!page || !isPage(page) || host(page) !== location.hostname) {
showBlank('详情页兜底不可用');
return;
}
clearVideo();
const v = videoEl();
if (v) v.style.display = 'none';
const fr = frameEl();
if (!fr) return;
fr.style.display = 'block';
fr.src = page;
msg('已切换到详情页内嵌兜底(不离开列表)。');
}
function clearVideo() {
const v = videoEl();
if (!v) return;
try { v.pause(); } catch {}
v.removeAttribute('src');
v.load();
}
function endHold() {
if (st.holdTimer) {
clearTimeout(st.holdTimer);
st.holdTimer = 0;
}
if (!st.speedHold) return;
st.speedHold = false;
const v = videoEl();
if (v) v.playbackRate = st.baseRate || 1;
msg('', 0);
}
function beginHold() {
endHold();
st.holdTimer = setTimeout(() => {
const v = videoEl();
if (!v || v.style.display === 'none') return;
st.baseRate = v.playbackRate || 1;
st.speedHold = true;
v.playbackRate = HOLD_SPEED;
msg(`长按倍速 ${HOLD_SPEED}x`, 0);
}, HOLD_DELAY);
}
function togglePlayPause() {
const v = videoEl();
if (!v || v.style.display === 'none') return;
if (v.paused) {
v.play().catch(() => {});
} else {
try { v.pause(); } catch {}
}
}
function showBlank(message) {
hideEmbeddedPage();
clearVideo();
const v = videoEl();
if (v) v.style.display = 'none';
msg(message || '暂无可播放地址');
}
function waitVideoReady(v, timeout = 4000) {
return new Promise((resolve, reject) => {
let done = false;
const cleanup = () => {
v.removeEventListener('loadedmetadata', onReady);
v.removeEventListener('canplay', onReady);
v.removeEventListener('error', onError);
clearTimeout(timer);
};
const finish = (ok, err) => {
if (done) return;
done = true;
cleanup();
ok ? resolve() : reject(err || new Error('video load failed'));
};
const onReady = () => finish(true);
const onError = () => finish(false, new Error(`video error ${v.error?.code || ''} ${v.error?.message || ''}`.trim()));
const timer = setTimeout(() => finish(false, new Error('video load timeout')), timeout);
v.addEventListener('loadedmetadata', onReady, { once: true });
v.addEventListener('canplay', onReady, { once: true });
v.addEventListener('error', onError, { once: true });
});
}
async function setVideoUrl(url, it, retry = 0) {
const v = videoEl();
if (!v) return;
if (!isMp4(url)) {
if (it?.page && isPage(it.page) && canUseIframeFallback()) showEmbeddedPage(it.page);
else showBlank('未解析到有效 mp4,且无可用详情页兜底。');
return;
}
hideEmbeddedPage();
try { v.pause(); } catch {}
v.style.display = 'block';
v.src = url;
v.controls = !MOBILE_MODE;
v.playsInline = true;
v.setAttribute('playsinline', '');
v.setAttribute('webkit-playsinline', '');
v.load();
try {
await waitVideoReady(v, 4000);
} catch (e) {
console.warn(`[${ID}] native video failed`, e);
if (retry < 1 && it) {
const fresh = await refreshCurrentItemFromApi(it);
if (fresh.length && fresh[0] !== url) {
it.cand = uniq([fresh[0], ...(it.cand || [])]);
it.ci = 0;
await setVideoUrl(it.cand[0], it, retry + 1);
return;
}
}
if (it?.page && isPage(it.page) && canUseIframeFallback()) {
showEmbeddedPage(it.page);
msg('原生内播失败,已切换到详情页内嵌兜底。', 1800);
} else {
showBlank('原生内播失败。请点“外开”播放当前视频(不影响列表)。');
}
return;
}
try {
await v.play();
msg('');
} catch {
msg('已加载视频,请点视频区域播放。', 1800);
}
}
async function openPlayer() {
ready(async () => {
css();
createUI();
const apiItems = await loadItemsFromApi();
if (apiItems.length) {
st.items = apiItems;
st.i = Math.min(st.i, Math.max(0, st.items.length - 1));
st.source = 'api';
btnText();
} else {
scan();
}
document.getElementById(`${ID}-ov`).classList.add('open');
st.open = true;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
if (!st.items.length) {
showBlank('暂未扫描到视频。请先滚动页面加载列表,或点“扫”。');
return;
}
await play();
});
}
function closePlayer() {
endHold();
hideEmbeddedPage();
clearVideo();
document.getElementById(`${ID}-ov`)?.classList.remove('open');
st.open = false;
hidePreview();
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
}
function msg(s, ms = 0) {
const m = document.getElementById(`${ID}-msg`);
if (!m) return;
m.textContent = s || '';
m.classList.toggle('show', !!s);
if (s && ms) setTimeout(() => { if (m.textContent === s) m.classList.remove('show'); }, ms);
}
function info() {
const it = st.items[st.i];
const t = document.getElementById(`${ID}-title`);
const c = document.getElementById(`${ID}-count`);
const pf = document.getElementById(`${ID}-prog-fill`);
if (t) t.textContent = it?.title || 'TikTok播放';
if (c) c.textContent = `${st.items.length ? st.i + 1 : 0}/${st.items.length}${it?.cand?.length ? ` 源${(it.ci || 0) + 1}/${it.cand.length}` : ''}`;
if (pf) pf.style.width = `${st.items.length ? ((st.i + 1) / st.items.length) * 100 : 0}%`;
if (st.pOpen) renderPreview('current');
}
async function play() {
if (!st.items.length || st.busy) return;
const it = st.items[st.i];
info();
msg('解析播放地址中…');
try {
st.busy = true;
it.cand = await candidates(it);
it.ci = Math.min(it.ci || 0, Math.max(0, it.cand.length - 1));
if (it.cand.length) {
await setVideoUrl(it.cand[it.ci], it);
} else {
if (it.page && host(it.page) === location.hostname && canUseIframeFallback()) {
showEmbeddedPage(it.page);
} else {
showBlank('未解析到 mp4。请点“外开”验证当前条目。');
}
}
} catch (e) {
showBlank(`加载失败:${e?.message || '未知错误'}`);
} finally {
st.busy = false;
info();
}
}
function go(d) {
if (!st.items.length || st.busy) return;
st.i = (st.i + d + st.items.length) % st.items.length;
hidePreview();
play();
}
function rand() {
if (!st.items.length) scan();
if (!st.items.length) return;
st.i = Math.floor(Math.random() * st.items.length);
hidePreview();
play();
}
function swap() {
const it = st.items[st.i];
if (!it?.cand || it.cand.length < 2) return msg('没有更多候选源', 1500);
it.ci = (it.ci + 1) % it.cand.length;
setVideoUrl(it.cand[it.ci], it);
info();
}
async function openSrc() {
const it = st.items[st.i];
if (!it) return;
let u = it?.cand?.[it.ci || 0] || '';
if (!isMp4(u)) {
try {
it.cand = await candidates(it);
it.ci = 0;
u = it.cand?.[0] || '';
} catch {
u = '';
}
}
if (isMp4(u)) {
window.open(u, '_blank', 'noopener,noreferrer');
return;
}
if (it.page && isPage(it.page)) {
window.open(it.page, '_blank', 'noopener,noreferrer');
msg('未解析到外开地址,已打开详情页兜底。', 1800);
} else {
msg('未解析到外开地址', 1800);
}
}
function rows(mode) {
if (!st.items.length) return [];
if (mode === 'random') return st.items.map((it, i) => ({ it, i })).sort(() => Math.random() - .5).slice(0, N);
if (mode === 'prev') st.off = Math.max(0, st.off - N);
else if (mode === 'next') st.off = Math.min(Math.max(0, st.items.length - N), st.off + N);
else st.off = Math.min(Math.max(0, st.items.length - N), st.i);
return st.items.slice(st.off, st.off + N).map((it, j) => ({ it, i: st.off + j }));
}
function renderPreview(mode = 'current') {
const p = document.getElementById(`${ID}-panel`);
const l = document.getElementById(`${ID}-list`);
const ca = document.getElementById(`${ID}-cap`);
if (!p || !l || !ca) return;
if (st.items.length === 0) {
p.classList.add('open');
st.pOpen = true;
ca.textContent = '预览';
l.innerHTML = '<div style="padding:14px;color:#ddd">暂无预览,请先滚动页面或点扫</div>';
return;
}
st.pOpen = true;
p.classList.add('open');
const r = rows(mode);
ca.textContent = mode === 'random' ? `随机5 / 共${st.items.length}` : `预览 ${st.off + 1}-${Math.min(st.off + N, st.items.length)} / 共${st.items.length}`;
l.innerHTML = r.map(({ it, i }) => {
const th = it.poster
? `<img class="${ID}-thumb" src="${esc(it.poster)}" referrerpolicy="no-referrer" loading="lazy">`
: `<div class="${ID}-thumb">#${i + 1}</div>`;
return `<div class="${ID}-card ${i === st.i ? 'active' : ''}" data-i="${i}">${th}<div><div class="${ID}-t">${esc(it.title || '视频')}</div><div class="${ID}-m">#${i + 1} · ${(it.url || it.page || '').includes('.mp4') ? 'mp4' : 'page'}</div></div></div>`;
}).join('') || '<div style="padding:14px;color:#ddd">暂无预览,请先滚动页面或点扫</div>';
l.querySelectorAll('[data-i]').forEach(e => e.onclick = () => {
st.i = Number(e.dataset.i);
hidePreview();
play();
});
}
function togglePreview() {
scan();
st.pOpen ? hidePreview() : renderPreview('current');
}
function hidePreview() {
st.pOpen = false;
document.getElementById(`${ID}-panel`)?.classList.remove('open');
}
function onWheel(e) {
if (!st.open) return;
e.preventDefault();
const n = Date.now();
if (n - st.wheel < 450) return;
st.wheel = n;
if (Math.abs(e.deltaY) > 20) go(e.deltaY > 0 ? 1 : -1);
}
function onTouchStart(e) {
if (!st.open) return;
const t = e.changedTouches?.[0];
if (!t) return;
st.ty = t.clientY;
st.tx = t.clientX;
st.touchAt = Date.now();
st.moved = false;
beginHold();
}
function onTouchMove(e) {
if (!st.open) return;
const t = e.changedTouches?.[0];
if (!t) return;
const dy = t.clientY - st.ty;
const dx = t.clientX - st.tx;
if (Math.abs(dy) > 12 || Math.abs(dx) > 12) {
st.moved = true;
endHold();
}
if (Math.abs(dy) > Math.abs(dx)) e.preventDefault();
}
function onTouchEnd(e) {
if (!st.open) return;
const t = e.changedTouches?.[0];
if (!t) return;
const dy = t.clientY - st.ty;
const dx = t.clientX - st.tx;
const wasHold = st.speedHold;
endHold();
if (!st.moved && !wasHold && Date.now() - st.touchAt < 220) {
e.preventDefault();
st.tapLockUntil = Date.now() + 350;
togglePlayPause();
return;
}
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 45) {
e.preventDefault();
go(dy < 0 ? 1 : -1);
}
}
function onGestureTap(e) {
if (Date.now() < st.tapLockUntil) return;
if (!st.open || st.moved || st.speedHold) return;
e.preventDefault();
togglePlayPause();
}
function onKey(e) {
if (!st.open) return;
const k = e.key.toLowerCase();
if (e.key === 'Escape') closePlayer();
else if (['arrowdown', 'pagedown', ' '].includes(k)) { e.preventDefault(); go(1); }
else if (['arrowup', 'pageup'].includes(k)) { e.preventDefault(); go(-1); }
else if (k === 'p') { e.preventDefault(); togglePreview(); }
else if (k === 'r') { e.preventDefault(); rand(); }
else if (k === 'x') { e.preventDefault(); swap(); }
else if (k === 's') { e.preventDefault(); scan(); msg(`已扫描 ${st.items.length} 条`, 1500); }
}
function auto() {
let tm = null;
const run = () => {
if (st.source !== 'api') scan();
ensureBtn();
};
ready(() => {
new MutationObserver(() => { clearTimeout(tm); tm = setTimeout(run, 700); }).observe(document.body, { childList: true, subtree: true });
run();
setTimeout(run, 1000);
setTimeout(run, 3000);
setTimeout(run, 6000);
});
}
try { injectPageHook(); } catch (e) { console.warn(`[${ID}] injectPageHook failed`, e); }
try { sandboxHook(); } catch (e) { console.warn(`[${ID}] sandboxHook failed`, e); }
try { ready(() => { css(); ensureBtn(); createUI(); auto(); }); } catch (e) { console.warn(`[${ID}] ready init failed`, e); }
try { setInterval(() => { ensureBtn(); btnText(); }, 1500); } catch (e) { console.warn(`[${ID}] keepalive failed`, e); }
})();
// @name Twitter排行榜:TikTok版 - Safe Current
// @namespace local.redfoxy.twitter-ranking-tiktok-safe
// @version 5.3.0-safe.current.9
// @description 私有仓库加载版:TikTok上下切换、预览5个、随机;优先解析mp4内播,移动端失败时优先重解析再外开兜底,PC保留详情页内嵌兜底。
// @author local-safe-rewrite
// @license Apache-2.0
// @match *://twitter-ero-video-ranking.com/*
// @match *://www.twitter-ero-video-ranking.com/*
// @match *://m.twitter-ero-video-ranking.com/*
// @match *://truvaze.com/*
// @match *://www.truvaze.com/*
// @match *://m.truvaze.com/*
// @match *://x-ero-anime.com/*
// @match *://www.x-ero-anime.com/*
// @match *://m.x-ero-anime.com/*
// @run-at document-start
// @noframes
// @grant none
// ==/UserScript==
(() => {
'use strict';
try {
if (location.hostname === 'video.twimg.com') {
console.log('[rf-tiktok-safe-current] skip direct mp4 page');
return;
}
} catch {}
const ID = 'rf-tiktok-safe-current';
const MAX = 300;
const N = 5;
const RESOLVE_TIMEOUT = 12000;
const MOBILE_OPEN_SOURCE_FIRST = true;
const mp4s = new Set();
const pages = new Map();
const bad = new Set();
const st = { items: [], i: 0, open: false, pOpen: false, off: 0, busy: false, wheel: 0, ty: 0, tx: 0, moved: false, source: 'dom' };
const U = (u, b = location.href) => { try { return new URL(String(u || '').trim(), b).href; } catch { return ''; } };
const host = u => { try { return new URL(u, location.href).hostname; } catch { return ''; } };
const isMp4 = u => host(u) === 'video.twimg.com' && /\.mp4(?:$|\?)/i.test(U(u));
const isPage = u => host(u) === location.hostname && /\/movie\//i.test(new URL(U(u)).pathname);
const isMobileEnv = () => /android|iphone|ipad|ipod|mobile|kiwi/i.test(navigator.userAgent || '');
const canUseIframeFallback = () => !isMobileEnv();
const txt = (s, n = 120) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
const esc = s => String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const uniq = a => [...new Set(a.filter(Boolean))];
function dec(s) { const t = document.createElement('textarea'); t.innerHTML = String(s || ''); return t.value; }
function ready(fn) { if (document.body) return fn(); const t = setInterval(() => { if (document.body) { clearInterval(t); fn(); } }, 80); }
function getMp4(s, base = location.href) {
const raw = dec(s).replace(/\\u0026/g, '&').replace(/&/g, '&');
const out = [];
const rs = [
/https?:\/\/video\.twimg\.com\/[^"' <>\n\r\\]+?\.mp4(?:\?[^"' <>\n\r\\]+)?/gi,
/https?:\\\/\\\/video\.twimg\.com\\\/[^"' <>\n\r]+?\.mp4(?:\?[^"' <>\n\r]+)?/gi
];
for (const re of rs) {
let m;
while ((m = re.exec(raw))) {
const u = U(m[0].replace(/\\\//g, '/').replace(/\\u0026/g, '&'), base);
if (isMp4(u)) out.push(u);
}
}
return uniq(out);
}
function getPages(s, base = location.href) {
const raw = dec(s);
const out = [];
const rs = [
/href=["']([^"']*\/movie\/[^"']+)["']/gi,
/(?:https?:\/\/[^"' <>\n\r]+)?\/[a-zA-Z-]+\/movie\/[A-Za-z0-9_-]+/g,
/(?:https?:\/\/[^"' <>\n\r]+)?\/movie\/[A-Za-z0-9_-]+/g
];
for (const re of rs) {
let m;
while ((m = re.exec(raw))) {
const u = U(m[1] || m[0], base);
if (isPage(u)) out.push(u);
}
}
return uniq(out);
}
function cap(s, src = 'text', base = location.href) {
let ch = false;
const foundMp4 = getMp4(s, base);
const foundPages = getPages(s, base);
for (const u of foundMp4) { if (!mp4s.has(u)) ch = true; mp4s.add(u); }
for (const u of foundPages) { if (!pages.has(u)) ch = true; pages.set(u, { url: u, title: 'Movie Page' }); }
if (ch) {
console.log(`[${ID}] captured from ${src}`, { mp4: mp4s.size, pages: pages.size });
try { scan(); } catch {}
btnText();
}