|
// ==UserScript== |
|
// @name TikTok: Keep Thumbnails playing |
|
// @namespace qwerty.xyz |
|
// @version 0.2 |
|
// @description Keep playing TikTok videos in a gallery after hovering away |
|
// @author Qwerty <[email protected]> |
|
// @icon https://qwerty.xyz/favicon.png |
|
// @match https://www.tiktok.com |
|
// @match https://tiktok.com/* |
|
// @match https://www.tiktok.com/* |
|
// @match http://tiktok.com/* |
|
// @grant none |
|
// @run-at document-idle |
|
// ==/UserScript== |
|
|
|
(async function () { |
|
'use strict' |
|
|
|
const sleep = (msec, retVal) => new Promise(resolve => setTimeout(resolve, msec, retVal)) |
|
|
|
window.Q_tiktokObserver?.disconnect() |
|
|
|
const getVideoContainer = () => document.querySelector('[data-e2e=user-post-item-list]') |
|
const getLikedContainer = () => document.querySelector('[data-e2e=user-liked-item-list]') |
|
const getUserPostsContainer = () => document.getElementById("user-post-item-list") |
|
|
|
|
|
const observer = new MutationObserver(mutations => { |
|
mutations.forEach(executeScript) |
|
}) |
|
|
|
let containerInterval = setInterval(() => { |
|
const container = getUserPostsContainer() ?? getVideoContainer() ?? getLikedContainer() |
|
if (container) { |
|
observer.observe(container, { |
|
childList: true, |
|
subtree: true, |
|
}) |
|
window.Q_tiktokObserver = observer |
|
clearInterval(containerInterval) |
|
} |
|
}, 5000) |
|
|
|
let headerInterval = setInterval(() => { |
|
let header = document.getElementById('app-header') |
|
if (header) { |
|
header.style.opacity = '0.5' |
|
clearInterval(headerInterval) |
|
} |
|
}, 5000) |
|
|
|
async function executeScript(mutation) { |
|
const { removedNodes, target } = mutation |
|
if (removedNodes.length) { |
|
const node_ = removedNodes[0] |
|
if (node_?.querySelector?.('video')) { |
|
const node = node_.cloneNode(true) |
|
target.appendChild(node) |
|
const video = node.querySelector('video') |
|
if (video) { |
|
video.style.width = '100%' |
|
video.style.height = '100%' |
|
video.style.objectFit = 'cover' |
|
video.muted = true |
|
video.loop = true |
|
await video.play() |
|
let observer = new IntersectionObserver( |
|
(entries) => { |
|
entries.forEach((entry) => { |
|
if (entry.intersectionRatio !== 1 && !video.paused) video.pause() |
|
else if (video.paused) video.play() |
|
}) |
|
}, { threshold: 0.2, rootMargin: '20px' }) |
|
observer.observe(video) |
|
} |
|
} |
|
} |
|
} |
|
|
|
function reconnectPreview(previewRoot, { url = window.__ttLastMp4Url } = {}) { |
|
if (!previewRoot) throw new Error('previewRoot missing') |
|
if (!url) throw new Error('No MP4 URL captured yet. Hover a tile first.') |
|
|
|
// 1) Find the existing (broken) cloned video and reuse its styles if needed |
|
const broken = previewRoot.querySelector('video') |
|
const styleToCopy = broken?.getAttribute('style') || '' |
|
|
|
// Stop it from doing anything / remove it |
|
if (broken) { |
|
try { broken.pause(); } catch { } |
|
broken.removeAttribute('src') |
|
broken.load?.() |
|
broken.remove() |
|
} |
|
|
|
// 2) Create our independent player |
|
let v = previewRoot.querySelector('video.__liveGallery') |
|
if (!v) { |
|
v = document.createElement('video') |
|
v.className = '__liveGallery' |
|
v.muted = true |
|
v.loop = true |
|
v.autoplay = true |
|
v.playsInline = true |
|
v.controls = false |
|
|
|
// Important: don’t use credentials unless you know you need them |
|
v.crossOrigin = 'anonymous' |
|
|
|
// Use the cloned style, but ensure it fills the container |
|
v.setAttribute( |
|
'style', |
|
styleToCopy || 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;' |
|
) |
|
if (!styleToCopy) { |
|
v.style.position = 'absolute' |
|
v.style.inset = '0' |
|
v.style.width = '100%' |
|
v.style.height = '100%' |
|
v.style.objectFit = 'cover' |
|
} |
|
|
|
previewRoot.appendChild(v) |
|
} |
|
|
|
// 3) Bind URL and play |
|
v.src = url |
|
|
|
v.onerror = () => { |
|
// Most likely: signed URL expired or policy changed. |
|
// You can recover by hovering the tile again to capture a fresh URL, then call reconnectPreview again. |
|
console.warn('===== Live gallery video error (likely expired URL). Re-hover to capture a new URL.') |
|
} |
|
|
|
v.play().catch(() => { }) |
|
return v |
|
} |
|
|
|
function getTileFromTarget(target) { |
|
const anchor = target?.closest?.('a[href*="/video/"]') |
|
if (!anchor) return null |
|
const tile = anchor.closest('[data-e2e="user-post-item"], [data-e2e="user-liked-item"]') |
|
?? anchor.closest('[data-e2e="user-post-item-list"] > div, [data-e2e="user-liked-item-list"] > div') |
|
?? anchor.parentElement |
|
return tile || null |
|
} |
|
|
|
function getTileKey(tile) { |
|
if (!tile) return null |
|
const link = tile.querySelector('a[href*="/video/"]') |
|
const href = link?.getAttribute('href') || '' |
|
const dataKey = tile.getAttribute('data-e2e') || tile.getAttribute('data-id') || '' |
|
return href || dataKey || `tile_${Math.random().toString(36).slice(2)}` |
|
} |
|
|
|
function ensurePreviewRoot(tile) { |
|
if (!tile) return null |
|
let root = tile.querySelector(':scope > .__liveGalleryRoot') |
|
if (!root) { |
|
root = document.createElement('div') |
|
root.className = '__liveGalleryRoot' |
|
root.style.position = 'absolute' |
|
root.style.inset = '0' |
|
root.style.width = '100%' |
|
root.style.height = '100%' |
|
root.style.zIndex = '9999' |
|
root.style.pointerEvents = 'none' |
|
|
|
const cs = getComputedStyle(tile) |
|
if (cs.position === 'static') tile.style.position = 'relative' |
|
tile.appendChild(root) |
|
} |
|
return root |
|
} |
|
|
|
async function waitForMp4Url(tileKey, timeoutMs = 2000) { |
|
const start = Date.now() |
|
const lastSeen = window.__ttLastMp4Url |
|
while (Date.now() - start < timeoutMs) { |
|
const url = window.__ttMp4ByHoverKey?.get?.(tileKey) || window.__ttLastMp4Url |
|
if (url && url !== lastSeen) return url |
|
await sleep(80) |
|
} |
|
return window.__ttMp4ByHoverKey?.get?.(tileKey) || window.__ttLastMp4Url |
|
} |
|
|
|
document.addEventListener('mouseover', async (evt) => { |
|
const tile = getTileFromTarget(evt.target) |
|
if (!tile) return |
|
|
|
const tileKey = getTileKey(tile) |
|
if (!tileKey) return |
|
|
|
window.__ttHoverKey = tileKey |
|
const previewRoot = ensurePreviewRoot(tile) |
|
if (!previewRoot) return |
|
|
|
const url = await waitForMp4Url(tileKey) |
|
if (!url) return |
|
|
|
try { |
|
reconnectPreview(previewRoot, { url }) |
|
} catch (error) { |
|
console.error('===== Live gallery reconnect failed', error) |
|
} |
|
}) |
|
})() |
|
|
|
// MP4 URL capture hook |
|
// -> window.__ttLastMp4Url |
|
void (() => { |
|
const isTiktokMp4 = (url, res) => { |
|
if (!url) return false |
|
if (!/\/video\/tos\//.test(url)) return false |
|
if (!/mime_type=video_mp4/.test(url)) return false |
|
const ct = res?.headers?.get?.('content-type') || '' |
|
return ct.includes('video/mp4') || true |
|
} |
|
|
|
window.__ttMp4ByHoverKey = new Map(); // optional: map tile key -> url |
|
window.__ttLastMp4Url = null |
|
|
|
const origFetch = window.fetch |
|
window.fetch = async (...args) => { |
|
const res = await origFetch(...args) |
|
try { |
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url |
|
if (isTiktokMp4(url, res)) { |
|
window.__ttLastMp4Url = url |
|
if (window.__ttHoverKey) window.__ttMp4ByHoverKey.set(window.__ttHoverKey, url) |
|
} |
|
} catch { } |
|
return res |
|
} |
|
|
|
console.log('===== TikTok MP4 capture hook installed') |
|
})() |