// ==UserScript== // @name Threads 阻擋自動載媒體 (iOS Safari) // @namespace https://github.com/mark1688288/digital-detox // @version 1.0.0-ios.2 // @description Threads 圖片/影片改為手動載入(iPhone Safari + Tampermonkey)。桌面 1.0 架構:hostname CDN、可串接 hook、fetch/XHR 雙擋;iOS:捲動安全 tap、unsafeWindow、WebKit 殘影 flush、playsinline。 // @author mark1688288 // @homepageURL https://github.com/mark1688288/digital-detox // @downloadURL https://raw.githubusercontent.com/mark1688288/digital-detox/main/ios/threads-detox-ios.js // @updateURL https://raw.githubusercontent.com/mark1688288/digital-detox/main/ios/threads-detox-ios.js // @match https://www.threads.net/* // @match https://threads.net/* // @match https://*.threads.net/* // @match https://www.threads.com/* // @match https://threads.com/* // @match https://*.threads.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=threads.com // @run-at document-start // @grant unsafeWindow // @sandbox raw // @license MIT // ==/UserScript== /** * iOS Safari / Tampermonkey * ------------------------- * - 目標:iPhone Safari + Tampermonkey(iPad 都用得,但以 touch phone 為準)。 * * 注入: * - `@sandbox raw` → 盡量 MAIN_WORLD。 * - `@grant unsafeWindow` → iOS Tampermonkey 若忽略 @sandbox 而入咗 sandbox, * 仍然 patch 頁面真身嘅 prototype / fetch(唔好只 patch sandbox)。 * * iOS 同桌面 1.0 嘅分別: * - 捲動安全:pointerdown 唔 preventDefault;超過 slop 或 pointercancel 當捲動; * 確認 tap 先喺 pointerup 解鎖。唔聽 touchmove。 * - WebKit 殘影:forceStrip 後 pause + load();閘 srcObject。 * - 解鎖影片設 playsinline,避免 iOS 強制全螢幕。 * - safe-area 浮動掣;:hover 只喺滑鼠。 */ (function () { 'use strict'; const VERSION = '1.0.0-ios.2'; const PLATFORM = 'iOS'; const INSTALL_KEY = 'threads-stop-media'; const W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; const doc = W.document || document; try { if (W[INSTALL_KEY] || W.__threadsStopMediaActive || W.__threadsVBActive) { return; } Object.defineProperty(W, INSTALL_KEY, { configurable: false, enumerable: false, value: VERSION + '+' + PLATFORM, writable: false }); W.__threadsStopMediaActive = true; W.__threadsVBActive = true; } catch (_) { if (W.__threadsStopMediaActive || W.__threadsVBActive) return; W.__threadsStopMediaActive = true; W.__threadsVBActive = true; } if (window !== W) { try { window.__threadsStopMediaActive = true; } catch (_) { /* ignore */ } } const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const STYLE_ID = 'threads-stop-media-ios-style'; const STATE_LOCKED = 'locked'; const STATE_UNLOCKED = 'unlocked'; const STATE_IGNORED = 'ignored'; const LOAD_ALL_ID = 'threads-stop-media-load-all'; const DS = { state: 'tSmState', processed: 'tSmProcessed', src: 'tSmSrc', srcset: 'tSmSrcset', poster: 'tSmPoster', unlockable: 'tSmUnlockable', managed: 'tSmManaged' }; const ImgProto = W.HTMLImageElement && W.HTMLImageElement.prototype; const SourceProto = W.HTMLSourceElement && W.HTMLSourceElement.prototype; const MediaProto = W.HTMLMediaElement && W.HTMLMediaElement.prototype; const VideoProto = W.HTMLVideoElement && W.HTMLVideoElement.prototype; const ElProto = W.Element && W.Element.prototype; const URLCtor = W.URL || URL; const RequestCtor = W.Request; const ResponseCtor = W.Response || Response; const BlobCtor = W.Blob || Blob; const XHRCtor = W.XMLHttpRequest; const IOCtor = W.IntersectionObserver || IntersectionObserver; const MOCtor = W.MutationObserver || MutationObserver; // ========================================== // 1. 設定 // ========================================== const CONFIG = { /** Meta 媒體 CDN hostname(含子網域);另會認 hostname 含 scontent */ mediaHosts: [ 'cdninstagram.com', 'fbcdn.net', 'facebook.com', 'instagram.com' ], /** CDN 路徑 sWxH 細於此值當 icon 放行 */ smallMediaMaxPx: 150, /** false → dialog / lightbox 內媒體自動放行 */ blockDialogMedia: false, /** 解鎖後吞 gallery 後續事件最長時間(ms) */ swallowTimeoutMs: 500, /** 解鎖影片後短暫擋 pause(ms) */ pauseBlockMs: 400, /** 連續解鎖防抖(ms)— 減少 iOS 雙擊/殘影連觸 */ unlockDebounceMs: 120, /** tap vs 捲動:位移超過呢個 px 就當捲動,唔解鎖 */ unlockSlopPx: 12, /** localStorage `threads_stopMediaLoader_debug=1` 亦可開 */ debug: false }; try { const ls = (W.localStorage || window.localStorage); if (ls && ls.getItem('threads_stopMediaLoader_debug') === '1') { CONFIG.debug = true; } } catch (_) { /* private mode */ } const stats = { blockedFetch: 0, blockedXhr: 0, lockedImg: 0, lockedVideo: 0, unlockedImg: 0, unlockedVideo: 0, ignored: 0, preloadDropped: 0 }; function log(...args) { if (!CONFIG.debug) return; console.debug( `%c[Threads StopMedia ${VERSION} ${PLATFORM}]`, 'color:#8ab4f8;font-weight:bold;', ...args ); } // ========================================== // 2. URL / hostname 工具 // ========================================== function isInstanceOf(value, sandboxCtor, pageCtor) { if (value == null) return false; try { if (sandboxCtor && value instanceof sandboxCtor) return true; } catch (_) { /* ignore */ } try { if (pageCtor && pageCtor !== sandboxCtor && value instanceof pageCtor) return true; } catch (_) { /* ignore */ } return false; } function isDataUrl(url) { return typeof url === 'string' && /^data:/i.test(url); } function isBlobUrl(url) { return typeof url === 'string' && /^blob:/i.test(url); } function isPlaceholder(url) { return typeof url === 'string' && ( url === PLACEHOLDER || url === '' || url === 'about:blank' ); } function parseUrl(value) { if (value == null) return null; let candidate = value; if (isInstanceOf(value, typeof Request !== 'undefined' ? Request : null, RequestCtor)) { candidate = value.url; } else if (isInstanceOf(value, typeof URL !== 'undefined' ? URL : null, URLCtor)) { candidate = value.href; } else if (typeof value === 'object' && typeof value.url === 'string') { candidate = value.url; } try { return new URLCtor(String(candidate), (W.location || location).href); } catch (_) { return null; } } function hostMatches(hostname, domain) { const h = String(hostname || '').toLowerCase(); const d = String(domain || '').toLowerCase(); if (!h || !d) return false; return h === d || h.endsWith('.' + d); } function isMetaMediaHost(hostname) { const h = String(hostname || '').toLowerCase(); if (!h) return false; if (h.includes('scontent')) return true; return CONFIG.mediaHosts.some((d) => hostMatches(h, d)); } function looksLikeMediaPath(urlObj) { if (!urlObj) return false; const path = (urlObj.pathname || '') + (urlObj.search || ''); if (/favicon/i.test(path)) return false; if (/\.(?:jpe?g|png|gif|webp|avif|heic|heif|bmp|mp4|m4v|mov|webm|mkv)(?:$|[?#])/i.test(path)) { return true; } if (/\/(?:v\/t\d+|o1\/v\/t\d+|t\d+\.\d+-\d+)\//i.test(urlObj.pathname || '')) { return true; } if (/[?&](?:stp|oh|oe|_nc_)/i.test(urlObj.search || '')) { return true; } return false; } function isTinyCdnSize(urlObj) { if (!urlObj) return false; const m = (urlObj.pathname + urlObj.search).match(/s(\d+)x(\d+)/i); if (!m) return false; const w = parseInt(m[1], 10); const h = parseInt(m[2], 10); return w <= CONFIG.smallMediaMaxPx && h <= CONFIG.smallMediaMaxPx; } function shouldBlockNetworkMedia(value) { const urlObj = parseUrl(value); if (!urlObj) return false; if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') return false; if (!isMetaMediaHost(urlObj.hostname)) return false; if (isTinyCdnSize(urlObj)) return false; if (/favicon/i.test(urlObj.pathname + urlObj.search)) return false; return looksLikeMediaPath(urlObj); } function toStoredUrl(raw) { if (raw == null) return ''; const s = String(raw).trim(); if (!s || isPlaceholder(s) || isDataUrl(s)) return ''; return s; } function isLoadAllTarget(el) { if (!el || el.nodeType !== 1) return false; if (el.id === LOAD_ALL_ID) return true; return !!(el.closest && el.closest('#' + LOAD_ALL_ID)); } // ========================================== // 3. CSS(touch 優先;hover 只喺真滑鼠) // ========================================== const cssRule = ` video[data-t-sm-state="locked"] { background-color: #1a1a1a !important; background-image: url('data:image/svg+xml;utf8,') !important; background-position: center !important; background-repeat: no-repeat !important; background-size: 48px !important; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1) !important; transition: background-size 0.2s ease, background-color 0.2s ease !important; width: 100% !important; height: 100% !important; min-height: 200px !important; cursor: pointer !important; -webkit-tap-highlight-color: transparent !important; -webkit-touch-callout: none !important; touch-action: manipulation !important; } img[data-t-sm-state="locked"] { content: url('${PLACEHOLDER}') !important; background-color: #1a1a1a !important; background-image: url('data:image/svg+xml;utf8,') !important; background-position: center !important; background-repeat: no-repeat !important; background-size: 32px !important; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1) !important; transition: background-size 0.2s ease, background-color 0.2s ease !important; cursor: pointer !important; min-width: 48px !important; min-height: 48px !important; -webkit-tap-highlight-color: transparent !important; -webkit-touch-callout: none !important; touch-action: manipulation !important; } video[data-t-sm-state="locked"]:active, img[data-t-sm-state="locked"]:active { background-color: #2a2a2a !important; } @media (hover: hover) and (pointer: fine) { video[data-t-sm-state="locked"]:hover, img[data-t-sm-state="locked"]:hover { background-color: #2a2a2a !important; } video[data-t-sm-state="locked"]:hover { background-size: 56px !important; } img[data-t-sm-state="locked"]:hover { background-size: 40px !important; } } video[data-t-sm-state="locked"]:focus-visible, img[data-t-sm-state="locked"]:focus-visible { background-color: #2a2a2a !important; outline: 2px solid #8ab4f8 !important; outline-offset: 2px !important; } [role="dialog"] video[data-t-sm-state="locked"], [role="dialog"] img[data-t-sm-state="locked"] { filter: none !important; background: none !important; content: none !important; cursor: default !important; min-height: 0 !important; } #${LOAD_ALL_ID} { position: fixed; z-index: 2147483646; right: max(12px, env(safe-area-inset-right, 0px)); bottom: max(12px, env(safe-area-inset-bottom, 0px)); padding: 12px 16px; border-radius: 22px; border: 1px solid rgba(255,255,255,0.2); background: rgba(26,26,26,0.92); color: #f5f5f5; font: 600 14px/1.3 system-ui, -apple-system, sans-serif; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.35); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); -webkit-tap-highlight-color: transparent; touch-action: manipulation; } #${LOAD_ALL_ID}:active { background: rgba(42,42,42,0.96); } #${LOAD_ALL_ID}:focus-visible { outline: 2px solid #8ab4f8; outline-offset: 2px; } #${LOAD_ALL_ID}[hidden] { display: none !important; } `; function injectStyle() { if (doc.getElementById(STYLE_ID)) return; const style = doc.createElement('style'); style.id = STYLE_ID; style.textContent = cssRule; (doc.head || doc.documentElement || doc).appendChild(style); } if (doc.head || doc.documentElement) injectStyle(); else doc.addEventListener('DOMContentLoaded', injectStyle, { once: true }); // ========================================== // 4. 可串接 hook // ========================================== function wrapSetter(proto, prop, makeSetter, makeGetter) { if (!proto) return false; let desc = Object.getOwnPropertyDescriptor(proto, prop); if (!desc || !desc.set) { let curr = Object.getPrototypeOf(proto); while (curr && (!desc || !desc.set)) { desc = Object.getOwnPropertyDescriptor(curr, prop); curr = Object.getPrototypeOf(curr); } } if (!desc || typeof desc.set !== 'function') { log('skip wrap', prop, 'no setter'); return false; } const prevSet = desc.set; const prevGet = desc.get; try { Object.defineProperty(proto, prop, { configurable: true, enumerable: !!desc.enumerable, get: makeGetter ? makeGetter(prevGet) : (prevGet ? function () { return prevGet.call(this); } : undefined), set: makeSetter(prevSet) }); return true; } catch (err) { log('wrapSetter failed', prop, err); return false; } } function wrapMethod(proto, prop, createReplacement) { if (!proto) return false; const desc = Object.getOwnPropertyDescriptor(proto, prop); if (!desc || typeof desc.value !== 'function') { const fn = proto[prop]; if (typeof fn !== 'function') return false; try { proto[prop] = createReplacement(fn); return true; } catch (err) { log('wrapMethod failed', prop, err); return false; } } try { Object.defineProperty(proto, prop, { ...desc, configurable: true, value: createReplacement(desc.value) }); return true; } catch (err) { log('wrapMethod define failed', prop, err); return false; } } function isUnlocking(el) { return !!(el && el._tSmUnlocking); } function withUnlocking(el, fn) { if (!el) return; el._tSmUnlocking = true; try { fn(); } finally { el._tSmUnlocking = false; } } function getState(el) { return el && el.dataset ? el.dataset[DS.state] : ''; } function setState(el, state) { if (el && el.dataset) el.dataset[DS.state] = state; } function isLocked(el) { return getState(el) === STATE_LOCKED; } function isUnlocked(el) { return getState(el) === STATE_UNLOCKED; } function markProcessed(el, value) { if (el && el.dataset) el.dataset[DS.processed] = value ? 'true' : 'false'; } function isProcessed(el) { return el && el.dataset && el.dataset[DS.processed] === 'true'; } function storeMediaUrl(el, key, value) { const s = toStoredUrl(value); if (s && el && el.dataset) el.dataset[key] = s; } function inDialog(el) { return !!(el && el.closest && el.closest('[role="dialog"]')); } const pending = new Set(); let scheduled = false; const raf = (W.requestAnimationFrame || requestAnimationFrame).bind(W); function queueElement(el) { if (!el || el.nodeType !== 1) return; pending.add(el); scheduleFlush(); } function scheduleFlush() { if (scheduled) return; scheduled = true; raf(flush); } function flush() { scheduled = false; if (pending.size === 0) return; const batch = Array.from(pending); pending.clear(); for (let i = 0; i < batch.length; i++) { try { handleElement(batch[i]); } catch (err) { log('handleElement error', err); } } refreshLoadAllButton(); } // ========================================== // 5. 網絡層:fetch + XHR // ========================================== function emptyMediaResponse() { return new ResponseCtor(new BlobCtor([]), { status: 204, statusText: 'No Content', headers: { 'Cache-Control': 'no-store' } }); } wrapMethod(W, 'fetch', (nativeFetch) => function (...args) { try { if (shouldBlockNetworkMedia(args[0])) { stats.blockedFetch++; log('block fetch', parseUrl(args[0]) && parseUrl(args[0]).href); return Promise.resolve(emptyMediaResponse()); } } catch (err) { log('fetch gate error', err); } return nativeFetch.apply(this, args); }); (function patchXHR() { if (!XHRCtor || !XHRCtor.prototype) return; const xhrProto = XHRCtor.prototype; const nativeOpen = xhrProto.open; const nativeSend = xhrProto.send; xhrProto.open = function (method, url, ...rest) { try { this._tSmUrl = url; this._tSmBlock = shouldBlockNetworkMedia(url); } catch (_) { this._tSmBlock = false; } return nativeOpen.call(this, method, url, ...rest); }; xhrProto.send = function (...args) { if (this._tSmBlock) { stats.blockedXhr++; log('block xhr', this._tSmUrl); const xhr = this; queueMicrotask(() => { try { Object.defineProperty(xhr, 'readyState', { configurable: true, get: () => 4 }); Object.defineProperty(xhr, 'status', { configurable: true, get: () => 204 }); Object.defineProperty(xhr, 'response', { configurable: true, get: () => '' }); Object.defineProperty(xhr, 'responseText', { configurable: true, get: () => '' }); xhr.dispatchEvent(new Event('readystatechange')); xhr.dispatchEvent(new Event('load')); xhr.dispatchEvent(new Event('loadend')); } catch (err) { log('xhr fake response error', err); } }); return; } return nativeSend.apply(this, args); }; })(); W.addEventListener('error', (e) => { const t = e.target; if (!t || !t.tagName) return; if ((t.tagName === 'IMG' || t.tagName === 'VIDEO' || t.tagName === 'SOURCE') && isLocked(t)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }, true); // ========================================== // 6. Preload 中和(唔 patch appendChild) // ========================================== function isImagePreloadLink(el) { if (!el || el.tagName !== 'LINK') return false; const rel = String(el.getAttribute('rel') || el.rel || '').toLowerCase(); const asAttr = String(el.getAttribute('as') || el.as || '').toLowerCase(); return rel.split(/\s+/).includes('preload') && asAttr === 'image'; } function neutralizePreloadLink(link) { if (!link || !isImagePreloadLink(link)) return false; try { link.removeAttribute('href'); link.removeAttribute('imagesrcset'); link.removeAttribute('imagesizes'); try { if (link.href) link.href = ''; } catch (_) { /* ignore */ } stats.preloadDropped++; log('drop preload link'); return true; } catch (err) { log('neutralizePreload error', err); return false; } } // ========================================== // 7. Gate media sources // ========================================== function gateImageSrc(img, value, prevSet) { if (isUnlocking(img) || isUnlocked(img) || getState(img) === STATE_IGNORED) { return prevSet.call(img, value); } if (isDataUrl(value) || isPlaceholder(value)) { return prevSet.call(img, value); } storeMediaUrl(img, DS.src, value); markProcessed(img, false); setState(img, STATE_LOCKED); queueElement(img); return prevSet.call(img, PLACEHOLDER); } function gateImageSrcset(img, value, prevSet) { if (isUnlocking(img) || isUnlocked(img) || getState(img) === STATE_IGNORED) { return prevSet.call(img, value); } if (value) storeMediaUrl(img, DS.srcset, value); markProcessed(img, false); setState(img, STATE_LOCKED); queueElement(img); return prevSet.call(img, ''); } function gateSourceAttr(source, prop, value, prevSet) { if (isUnlocking(source)) { return prevSet ? prevSet.call(source, value) : undefined; } const key = prop === 'srcset' ? DS.srcset : DS.src; if (value) storeMediaUrl(source, key, value); try { const pic = source.closest && source.closest('picture'); if (pic) { const img = pic.querySelector('img'); if (img && !isUnlocked(img) && getState(img) !== STATE_IGNORED) { setState(img, STATE_LOCKED); markProcessed(img, false); if (prop === 'srcset' && value) storeMediaUrl(img, DS.srcset, value); if (prop === 'src' && value) storeMediaUrl(img, DS.src, value); queueElement(img); } } const vid = source.closest && source.closest('video'); if (vid && !isUnlocked(vid) && getState(vid) !== STATE_IGNORED) { setState(vid, STATE_LOCKED); markProcessed(vid, false); queueElement(vid); } } catch (_) { /* ignore */ } if (prop === 'srcset' && prevSet) return prevSet.call(source, ''); if (prop === 'src') return; return prevSet ? prevSet.call(source, value) : undefined; } function gateVideoSrc(video, value, prevSet) { if (isUnlocking(video) || isUnlocked(video) || getState(video) === STATE_IGNORED) { return prevSet.call(video, value); } if (isDataUrl(value) || isPlaceholder(value)) { return prevSet.call(video, value); } if (isBlobUrl(value) && !isUnlocked(video)) { storeMediaUrl(video, DS.src, value); setState(video, STATE_LOCKED); markProcessed(video, false); queueElement(video); return; } storeMediaUrl(video, DS.src, value); setState(video, STATE_LOCKED); markProcessed(video, false); queueElement(video); return; } function gateVideoPoster(video, value, prevSet) { if (isUnlocking(video) || isUnlocked(video) || getState(video) === STATE_IGNORED) { return prevSet.call(video, value); } if (isDataUrl(value) || isPlaceholder(value)) { return prevSet.call(video, value); } storeMediaUrl(video, DS.poster, value); queueElement(video); return prevSet.call(video, PLACEHOLDER); } function gateSrcObject(media, value, prevSet) { if (isUnlocking(media) || isUnlocked(media) || getState(media) === STATE_IGNORED) { return prevSet.call(media, value); } if (media.tagName !== 'VIDEO' && media.tagName !== 'AUDIO') { return prevSet.call(media, value); } media._tSmSrcObject = value; setState(media, STATE_LOCKED); markProcessed(media, false); queueElement(media); return; } wrapSetter( ImgProto, 'src', (prevSet) => function (val) { try { return gateImageSrc(this, val, prevSet); } catch (err) { log('img.src error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.src] && isLocked(this)) { return this.dataset[DS.src]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( ImgProto, 'srcset', (prevSet) => function (val) { try { return gateImageSrcset(this, val, prevSet); } catch (err) { log('img.srcset error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.srcset] && isLocked(this)) { return this.dataset[DS.srcset]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( SourceProto, 'src', (prevSet) => function (val) { try { return gateSourceAttr(this, 'src', val, prevSet); } catch (err) { log('source.src error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.src]) { return this.dataset[DS.src]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( SourceProto, 'srcset', (prevSet) => function (val) { try { return gateSourceAttr(this, 'srcset', val, prevSet); } catch (err) { log('source.srcset error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.srcset]) { return this.dataset[DS.srcset]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( MediaProto, 'src', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO') return gateVideoSrc(this, val, prevSet); return prevSet.call(this, val); } catch (err) { log('media.src error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (this.tagName === 'VIDEO' && !isUnlocking(this) && this.dataset && this.dataset[DS.src] && isLocked(this)) { return this.dataset[DS.src]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( MediaProto, 'srcObject', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO' || this.tagName === 'AUDIO') { return gateSrcObject(this, val, prevSet); } return prevSet.call(this, val); } catch (err) { log('media.srcObject error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this._tSmSrcObject != null && isLocked(this)) { return this._tSmSrcObject; } return prevGet ? prevGet.call(this) : null; } ); wrapSetter( MediaProto, 'autoplay', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO' && !isUnlocking(this) && !isUnlocked(this) && getState(this) !== STATE_IGNORED) { return prevSet.call(this, false); } return prevSet.call(this, val); } catch (err) { return prevSet.call(this, val); } } ); wrapSetter( MediaProto, 'preload', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO' && !isUnlocking(this) && !isUnlocked(this) && getState(this) !== STATE_IGNORED) { return prevSet.call(this, 'none'); } return prevSet.call(this, val); } catch (err) { return prevSet.call(this, val); } } ); wrapSetter( VideoProto, 'poster', (prevSet) => function (val) { try { return gateVideoPoster(this, val, prevSet); } catch (err) { log('video.poster error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.poster] && isLocked(this)) { return this.dataset[DS.poster]; } return prevGet ? prevGet.call(this) : ''; } ); (function patchSetAttribute() { if (!ElProto || typeof ElProto.setAttribute !== 'function') return; const prev = ElProto.setAttribute; ElProto.setAttribute = function (name, value) { try { if (!this || typeof name !== 'string') { return prev.call(this, name, value); } const attr = name.toLowerCase(); if (this.tagName === 'LINK') { if (attr === 'rel' || attr === 'as' || attr === 'href' || attr === 'imagesrcset' || attr === 'imagesizes') { const result = prev.call(this, name, value); if (isImagePreloadLink(this)) neutralizePreloadLink(this); return result; } } if (isUnlocking(this)) { return prev.call(this, name, value); } if (this.tagName === 'IMG') { if (attr === 'src') { if (isUnlocked(this) || getState(this) === STATE_IGNORED || isDataUrl(value) || isPlaceholder(value)) { return prev.call(this, name, value); } storeMediaUrl(this, DS.src, value); markProcessed(this, false); setState(this, STATE_LOCKED); queueElement(this); return prev.call(this, name, PLACEHOLDER); } if (attr === 'srcset') { if (isUnlocked(this) || getState(this) === STATE_IGNORED) { return prev.call(this, name, value); } if (value) storeMediaUrl(this, DS.srcset, value); markProcessed(this, false); setState(this, STATE_LOCKED); queueElement(this); return prev.call(this, name, ''); } } if (this.tagName === 'SOURCE' && (attr === 'src' || attr === 'srcset')) { const key = attr === 'srcset' ? DS.srcset : DS.src; if (value) storeMediaUrl(this, key, value); try { const pic = this.closest('picture'); const img = pic && pic.querySelector('img'); if (img && !isUnlocked(img) && getState(img) !== STATE_IGNORED) { setState(img, STATE_LOCKED); markProcessed(img, false); queueElement(img); } const vid = this.closest('video'); if (vid && !isUnlocked(vid) && getState(vid) !== STATE_IGNORED) { setState(vid, STATE_LOCKED); markProcessed(vid, false); queueElement(vid); } } catch (_) { /* ignore */ } if (attr === 'srcset') return prev.call(this, name, ''); return; } if (this.tagName === 'VIDEO') { if (attr === 'src') { if (isUnlocked(this) || getState(this) === STATE_IGNORED) { return prev.call(this, name, value); } storeMediaUrl(this, DS.src, value); setState(this, STATE_LOCKED); markProcessed(this, false); queueElement(this); return; } if (attr === 'poster') { if (isUnlocked(this) || getState(this) === STATE_IGNORED || isDataUrl(value) || isPlaceholder(value)) { return prev.call(this, name, value); } storeMediaUrl(this, DS.poster, value); queueElement(this); return prev.call(this, name, PLACEHOLDER); } if (attr === 'autoplay') return; if (attr === 'preload') return prev.call(this, name, 'none'); } } catch (err) { log('setAttribute error', err); } return prev.call(this, name, value); }; })(); const originalPlay = VideoProto && VideoProto.play; wrapMethod(VideoProto, 'play', (nativePlay) => function (...args) { if (isUnlocked(this) || getState(this) === STATE_IGNORED) { return nativePlay.apply(this, args); } if (!CONFIG.blockDialogMedia && inDialog(this)) { return nativePlay.apply(this, args); } setState(this, STATE_LOCKED); return Promise.reject(new DOMException('Autoplay blocked by Threads Stop Media (iOS)', 'NotAllowedError')); }); // ========================================== // 8. IntersectionObserver // ========================================== const visibleLocked = new Set(); const io = new IOCtor((entries) => { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const el = entry.target; if (entry.isIntersecting && isLocked(el) && el.isConnected) { visibleLocked.add(el); } else { visibleLocked.delete(el); } } refreshLoadAllButton(); }, { root: null, rootMargin: '0px', threshold: 0.05 }); function observeLocked(el) { if (!el) return; try { io.observe(el); } catch (_) { /* ignore */ } } function unobserve(el) { if (!el) return; visibleLocked.delete(el); try { io.unobserve(el); } catch (_) { /* ignore */ } } function pruneVisible() { for (const el of Array.from(visibleLocked)) { if (!el.isConnected || !isLocked(el)) visibleLocked.delete(el); } } // ========================================== // 9. 分類 // ========================================== function parsePxAttr(val) { if (val == null || val === '') return null; const s = String(val).trim(); if (!s || s.includes('%')) return null; const n = parseInt(s, 10); return Number.isFinite(n) ? n : null; } function isSmallByAttributes(img) { const w = parsePxAttr(img.getAttribute('width')); const h = parsePxAttr(img.getAttribute('height')); if (w != null && w > 0 && w < 50) return true; if (h != null && h > 0 && h < 50) return true; return false; } function shouldIgnoreImage(img) { if (!img || img.tagName !== 'IMG') return true; if (img.closest('svg')) return true; if (!CONFIG.blockDialogMedia && inDialog(img)) return true; const alt = String(img.getAttribute('alt') || img.alt || '').toLowerCase(); if ( alt.includes('profile') || alt.includes('avatar') || alt.includes('頭像') || alt.includes('プロフィール') || alt.includes('프로필') ) { return true; } if (isSmallByAttributes(img)) return true; const stored = img.dataset[DS.src] || ''; const attrSrc = img.getAttribute('src') || ''; const check = (stored || attrSrc).toLowerCase(); if (check.includes('favicon')) return true; try { const link = img.closest('a[role="link"], a[href*="/@"], a[href*="/user"]'); if (link) { const r = link.getBoundingClientRect(); if (r.width > 0 && r.height > 0 && r.width <= 64 && r.height <= 64) { return true; } } } catch (_) { /* ignore */ } return false; } function isNearVideoPoster(img) { let curr = img.parentElement; for (let i = 0; i < 2 && curr; i++) { if (curr.querySelector && curr.querySelector('video')) return true; curr = curr.parentElement; } return false; } function silenceAsIgnored(el) { setState(el, STATE_IGNORED); markProcessed(el, true); unobserve(el); el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); stats.ignored++; } function makeUnlockableA11y(el, kind) { if (!el || el.dataset[DS.unlockable] === '1') return; el.dataset[DS.unlockable] = '1'; if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute( 'aria-label', kind === 'video' ? '載入影片(點擊)' : '載入圖片(點擊)' ); } function stripA11y(el) { if (!el) return; delete el.dataset[DS.unlockable]; el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); } function ensurePlaysInline(video) { try { video.setAttribute('playsinline', ''); video.setAttribute('webkit-playsinline', ''); video.playsInline = true; } catch (_) { /* ignore */ } } function forceStripImageNetwork(img) { const attrSrc = img.getAttribute('src'); const attrSrcset = img.getAttribute('srcset'); if (attrSrc && !isPlaceholder(attrSrc) && !isDataUrl(attrSrc)) { storeMediaUrl(img, DS.src, attrSrc); } if (attrSrcset && attrSrcset.trim()) { storeMediaUrl(img, DS.srcset, attrSrcset); } withUnlocking(img, () => { try { if (img.getAttribute('srcset')) img.removeAttribute('srcset'); if (!isPlaceholder(img.getAttribute('src') || '')) { img.setAttribute('src', PLACEHOLDER); } } catch (err) { log('forceStripImage error', err); } }); } /** * iOS WebKit:清 src / source / poster / srcObject 後 video.load(), * 洗走已預載嘅第一幀殘影,先見到 CSS 佔位。 */ function forceStripVideoNetwork(video) { const attrSrc = video.getAttribute('src'); const attrPoster = video.getAttribute('poster'); if (attrSrc && !isPlaceholder(attrSrc) && !isDataUrl(attrSrc)) { storeMediaUrl(video, DS.src, attrSrc); } if (attrPoster && !isPlaceholder(attrPoster) && !isDataUrl(attrPoster)) { storeMediaUrl(video, DS.poster, attrPoster); } let needsFlush = false; ensurePlaysInline(video); withUnlocking(video, () => { try { video.removeAttribute('autoplay'); try { video.autoplay = false; } catch (_) { /* ignore */ } try { video.preload = 'none'; } catch (_) { /* ignore */ } if (video.getAttribute('src')) { video.removeAttribute('src'); needsFlush = true; } if (attrPoster && !isPlaceholder(attrPoster)) { video.setAttribute('poster', PLACEHOLDER); } const sources = video.querySelectorAll('source'); for (let i = 0; i < sources.length; i++) { const s = sources[i]; const ss = s.getAttribute('src'); const sset = s.getAttribute('srcset'); if (ss) storeMediaUrl(s, DS.src, ss); if (sset) storeMediaUrl(s, DS.srcset, sset); s._tSmUnlocking = true; try { if (ss) { s.removeAttribute('src'); needsFlush = true; } if (sset) s.removeAttribute('srcset'); } finally { s._tSmUnlocking = false; } } if (video.srcObject && !isUnlocked(video)) { try { if (video._tSmSrcObject == null) video._tSmSrcObject = video.srcObject; video.srcObject = null; needsFlush = true; } catch (_) { /* ignore */ } } try { video.pause(); } catch (_) { /* ignore */ } if (needsFlush || (typeof video.readyState === 'number' && video.readyState > 0)) { try { video.load(); } catch (_) { /* ignore */ } } } catch (err) { log('forceStripVideo error', err); } }); } function processVideo(video) { if (!video || video.tagName !== 'VIDEO') return; if (isUnlocked(video)) return; if (isProcessed(video) && isLocked(video)) { observeLocked(video); return; } if (!CONFIG.blockDialogMedia && inDialog(video)) { silenceAsIgnored(video); return; } const firstManage = video.dataset[DS.managed] !== '1'; video.dataset[DS.managed] = '1'; markProcessed(video, true); setState(video, STATE_LOCKED); forceStripVideoNetwork(video); makeUnlockableA11y(video, 'video'); observeLocked(video); if (firstManage) { stats.lockedVideo++; log('locked video'); } let curr = video.parentElement; for (let i = 0; i < 2 && curr; i++) { const imgs = curr.querySelectorAll('img'); for (let j = 0; j < imgs.length; j++) { const img = imgs[j]; if (isLocked(img) || !isProcessed(img)) { silenceAsIgnored(img); stripA11y(img); } } curr = curr.parentElement; } } function processImage(img) { if (!img || img.tagName !== 'IMG') return; if (isUnlocked(img)) return; if (isProcessed(img) && isLocked(img)) { if (shouldIgnoreImage(img)) { unlockImage(img, true); } else { observeLocked(img); } return; } if (shouldIgnoreImage(img)) { markProcessed(img, true); unlockImage(img, true); return; } if (isNearVideoPoster(img)) { markProcessed(img, true); silenceAsIgnored(img); forceStripImageNetwork(img); return; } const firstManage = img.dataset[DS.managed] !== '1'; img.dataset[DS.managed] = '1'; markProcessed(img, true); setState(img, STATE_LOCKED); forceStripImageNetwork(img); makeUnlockableA11y(img, 'image'); observeLocked(img); if (firstManage) { stats.lockedImg++; log('locked image'); } } function handleElement(el) { if (!el || el.nodeType !== 1) return; if (el.tagName === 'VIDEO') processVideo(el); else if (el.tagName === 'IMG') processImage(el); else if (el.tagName === 'LINK' && isImagePreloadLink(el)) neutralizePreloadLink(el); } // ========================================== // 10. 解鎖 // ========================================== function unlockImage(img, silent) { if (!img || img.tagName !== 'IMG') return; setState(img, silent ? STATE_IGNORED : STATE_UNLOCKED); markProcessed(img, true); delete img.dataset[DS.managed]; unobserve(img); stripA11y(img); withUnlocking(img, () => { try { const picture = img.closest('picture'); if (picture) { const sources = picture.querySelectorAll('source'); for (let i = 0; i < sources.length; i++) { const source = sources[i]; source._tSmUnlocking = true; try { if (source.dataset[DS.srcset]) source.srcset = source.dataset[DS.srcset]; if (source.dataset[DS.src]) source.src = source.dataset[DS.src]; } finally { source._tSmUnlocking = false; } } } if (img.dataset[DS.src]) img.src = img.dataset[DS.src]; if (img.dataset[DS.srcset]) img.srcset = img.dataset[DS.srcset]; } catch (err) { log('unlockImage error', err); } }); if (!silent) { stats.unlockedImg++; log('unlocked image'); } refreshLoadAllButton(); } function unlockVideo(video) { if (!video || video.tagName !== 'VIDEO') return; setState(video, STATE_UNLOCKED); markProcessed(video, true); delete video.dataset[DS.managed]; unobserve(video); stripA11y(video); ensurePlaysInline(video); withUnlocking(video, () => { try { const sources = video.querySelectorAll('source'); for (let i = 0; i < sources.length; i++) { const source = sources[i]; source._tSmUnlocking = true; try { if (source.dataset[DS.srcset]) source.srcset = source.dataset[DS.srcset]; if (source.dataset[DS.src]) source.src = source.dataset[DS.src]; } finally { source._tSmUnlocking = false; } } if (video.dataset[DS.src]) video.src = video.dataset[DS.src]; if (video._tSmSrcObject != null) { try { video.srcObject = video._tSmSrcObject; } catch (_) { /* ignore */ } } if (video.dataset[DS.poster]) video.poster = video.dataset[DS.poster]; if (video.dataset[DS.src] || video._tSmSrcObject != null || video.querySelector('source')) { try { video.load(); } catch (_) { /* ignore */ } } } catch (err) { log('unlockVideo error', err); } }); video._tSmBlockPause = true; if (!video._tSmPauseWrapped) { video._tSmPauseWrapped = true; const nativePause = MediaProto && MediaProto.pause ? MediaProto.pause : video.pause.bind(video); video.pause = function (...args) { if (this._tSmBlockPause) return; return nativePause.apply(this, args); }; } if (typeof originalPlay === 'function') { try { const p = originalPlay.call(video); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch (_) { /* autoplay policy */ } } setTimeout(() => { if (video) video._tSmBlockPause = false; }, CONFIG.pauseBlockMs); stats.unlockedVideo++; log('unlocked video'); refreshLoadAllButton(); } function unlockElement(el) { if (!el) return; if (el.tagName === 'VIDEO') unlockVideo(el); else if (el.tagName === 'IMG') unlockImage(el, false); } function unlockAllVisible() { pruneVisible(); const list = Array.from(visibleLocked); for (let i = 0; i < list.length; i++) { unlockElement(list[i]); } log('unlock all visible', list.length); } // ========================================== // 11. 「載入可見媒體」浮動掣(safe-area) // ========================================== let loadAllBtn = null; function ensureLoadAllButton() { if (loadAllBtn || !doc.documentElement) return; loadAllBtn = doc.createElement('button'); loadAllBtn.type = 'button'; loadAllBtn.id = LOAD_ALL_ID; loadAllBtn.hidden = true; loadAllBtn.setAttribute('aria-label', '載入目前可見嘅圖片同影片'); loadAllBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); unlockAllVisible(); }); (doc.body || doc.documentElement).appendChild(loadAllBtn); } function refreshLoadAllButton() { pruneVisible(); if (!loadAllBtn) { if (doc.body || doc.documentElement) ensureLoadAllButton(); } if (!loadAllBtn) return; const n = visibleLocked.size; if (n === 0) { loadAllBtn.hidden = true; return; } loadAllBtn.hidden = false; loadAllBtn.textContent = `📥 載入可見(${n})`; } // ========================================== // 12. 交互:iOS 捲動安全 tap // ========================================== let isSwallowing = false; let swallowTimer = null; let lastUnlockAt = 0; let gesture = null; function clearSwallow() { isSwallowing = false; if (swallowTimer != null) { clearTimeout(swallowTimer); swallowTimer = null; } } function beginSwallow() { isSwallowing = true; lastUnlockAt = Date.now(); if (swallowTimer != null) clearTimeout(swallowTimer); swallowTimer = setTimeout(clearSwallow, CONFIG.swallowTimeoutMs); } function eventPoint(e) { if (e.touches && e.touches[0]) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } if (e.changedTouches && e.changedTouches[0]) { return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; } if (typeof e.clientX === 'number' && typeof e.clientY === 'number') { return { x: e.clientX, y: e.clientY }; } return null; } function findLockedFromPoint(x, y) { let stack; try { stack = doc.elementsFromPoint(x, y); } catch (_) { stack = []; } for (let i = 0; i < stack.length; i++) { let el = stack[i]; if (isLoadAllTarget(el)) return null; while (el && el !== doc.documentElement) { if ( el.nodeType === 1 && (el.tagName === 'IMG' || el.tagName === 'VIDEO') && isLocked(el) ) { return el; } el = el.parentElement; } } for (const el of visibleLocked) { if (!el.isConnected || !isLocked(el)) continue; const r = el.getBoundingClientRect(); if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { return el; } } return null; } function movedBeyondSlop(x, y, origin) { const slop = CONFIG.unlockSlopPx; const dx = x - origin.x; const dy = y - origin.y; return (dx * dx + dy * dy) > (slop * slop); } function commitUnlock(el, e) { if (!el || !el.isConnected || !isLocked(el)) return false; if (Date.now() - lastUnlockAt < CONFIG.unlockDebounceMs) { if (e) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } return true; } unlockElement(el); beginSwallow(); if (e) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } return true; } function onPrimaryDown(e) { if (!e.isTrusted) return; if (e.isPrimary === false) return; if (typeof e.button === 'number' && e.button !== 0) return; if (isLoadAllTarget(e.target)) { gesture = null; return; } if (isSwallowing) clearSwallow(); const pt = eventPoint(e); if (!pt) { gesture = null; return; } const hit = findLockedFromPoint(pt.x, pt.y); if (!hit) { gesture = null; return; } gesture = { el: hit, x: pt.x, y: pt.y, id: e.pointerId, moved: false }; } function onPrimaryMove(e) { if (!gesture || gesture.moved) return; if (e.pointerId != null && gesture.id != null && e.pointerId !== gesture.id) return; const pt = eventPoint(e); if (!pt) return; if (movedBeyondSlop(pt.x, pt.y, gesture)) { gesture.moved = true; gesture = null; } } function onPrimaryUp(e) { if (!e.isTrusted) return; if (!gesture || gesture.moved) { gesture = null; return; } if (e.pointerId != null && gesture.id != null && e.pointerId !== gesture.id) { return; } const el = gesture.el; gesture = null; commitUnlock(el, e); } function onPrimaryCancel() { gesture = null; } function onSwallowable(e) { if (!e.isTrusted) return; if (!isSwallowing) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (e.type === 'click' || e.type === 'dblclick' || e.type === 'auxclick') { clearSwallow(); } } function onClickCapture(e) { if (!e.isTrusted) return; if (isLoadAllTarget(e.target)) return; if (isSwallowing) { onSwallowable(e); return; } const t = e.target; let locked = null; if (t && (t.tagName === 'IMG' || t.tagName === 'VIDEO') && isLocked(t)) { locked = t; } else { const pt = eventPoint(e); if (pt) locked = findLockedFromPoint(pt.x, pt.y); } if (!locked) return; commitUnlock(locked, e); } function onKeyActivate(e) { if (!e.isTrusted) return; if (e.key !== 'Enter' && e.key !== ' ') return; const t = e.target; if (!t || (t.tagName !== 'IMG' && t.tagName !== 'VIDEO')) return; if (!isLocked(t)) return; e.preventDefault(); e.stopPropagation(); commitUnlock(t, e); } const hasPointer = typeof W.PointerEvent === 'function' || typeof window.PointerEvent === 'function'; const capturePassive = { capture: true, passive: true }; const captureNonPassive = { capture: true, passive: false }; if (hasPointer) { W.addEventListener('pointerdown', onPrimaryDown, capturePassive); W.addEventListener('pointermove', onPrimaryMove, capturePassive); W.addEventListener('pointerup', onPrimaryUp, captureNonPassive); W.addEventListener('pointercancel', onPrimaryCancel, capturePassive); ['click', 'dblclick', 'auxclick'].forEach((evt) => { W.addEventListener(evt, onSwallowable, captureNonPassive); }); } else { W.addEventListener('touchstart', onPrimaryDown, capturePassive); W.addEventListener('touchend', onPrimaryUp, captureNonPassive); W.addEventListener('touchcancel', onPrimaryCancel, capturePassive); ['click', 'dblclick'].forEach((evt) => { W.addEventListener(evt, onSwallowable, captureNonPassive); }); } W.addEventListener('click', onClickCapture, captureNonPassive); W.addEventListener('keydown', onKeyActivate, { capture: true }); // ========================================== // 13. MutationObserver(只 childList;src 靠 setter) // ========================================== function queueNode(node) { if (!node || node.nodeType !== 1) return; if (node.tagName === 'IMG' || node.tagName === 'VIDEO' || node.tagName === 'LINK') { pending.add(node); } if (node.querySelectorAll) { const list = node.querySelectorAll('img, video, link[rel="preload"]'); for (let i = 0; i < list.length; i++) pending.add(list[i]); } if (pending.size) scheduleFlush(); } function scanAll() { try { doc.querySelectorAll('img, video, link[rel="preload"]').forEach((el) => pending.add(el)); scheduleFlush(); } catch (err) { log('scanAll error', err); } } const observer = new MOCtor((mutations) => { for (let i = 0; i < mutations.length; i++) { const m = mutations[i]; if (m.type === 'childList') { const nodes = m.addedNodes; for (let j = 0; j < nodes.length; j++) queueNode(nodes[j]); } } if (pending.size) scheduleFlush(); }); function startObserver() { try { observer.observe(doc, { childList: true, subtree: true }); } catch (err) { try { const root = doc.documentElement || doc; observer.observe(root, { childList: true, subtree: true }); } catch (err2) { log('observe failed', err2 || err); } } injectStyle(); ensureLoadAllButton(); scanAll(); } if (doc.documentElement || doc.nodeType === 9) startObserver(); else doc.addEventListener('DOMContentLoaded', startObserver, { once: true }); if (doc.readyState === 'loading') { doc.addEventListener('DOMContentLoaded', () => { injectStyle(); ensureLoadAllButton(); scanAll(); }, { once: true }); } else { ensureLoadAllButton(); scanAll(); } W.addEventListener('pageshow', () => { injectStyle(); ensureLoadAllButton(); scanAll(); }); try { Object.defineProperty(W, 'ThreadsStopMedia', { configurable: true, enumerable: false, get() { return { version: VERSION, platform: PLATFORM, config: { ...CONFIG }, stats: { ...stats }, visibleLocked: visibleLocked.size, hasPointerEvents: hasPointer, pageWindow: W !== window }; } }); } catch (_) { /* ignore */ } log('initialized', { platform: PLATFORM, hasPointer, pageWindow: W !== window, mediaHosts: CONFIG.mediaHosts, blockDialogMedia: CONFIG.blockDialogMedia }); })();