Video Quality Controller for X (Twitter) (Chromium browsers only)

Force highest quality playback for X (Twitter). Fixed UI layout shifts.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name                Video Quality Controller for X (Twitter) (Chromium browsers only)
// @version             1.0.0
// @description         Force highest quality playback for X (Twitter). Fixed UI layout shifts.
// @match               https://x.com/*
// @match               https://mobile.x.com/*
// @match               https://pro.x.com/*
// @match               https://twitter.com/*
// @match               https://mobile.twitter.com/*
// @match               https://pro.twitter.com/*
// @exclude             https://x.com/i/cards-frame/*
// @exclude             https://*.x.com/i/cards-frame/*
// @exclude             https://twitter.com/i/cards-frame/*
// @exclude             https://*.twitter.com/i/cards-frame/*
// @grant               none
// @run-at              document-start
// @namespace https://greasyfork.org/users/1474347
// ==/UserScript==

(function () {
    'use strict';
    if (window.__VQFFT) return; window.__VQFFT = 1;

    // --- CONFIGURATION ---
    const KEYS = { QUALITY: 'vqfft_settings_quality_v31', MIN: 'vqfft_settings_min_v31' };
    const LEVELS = { 'Auto': 'Auto', 'Best': 'Highest', '1080P': '1080p', '720P': '720p', 'Worst': 'Saver' };
    const ORDER = ['Best', '1080P', '720P', 'Auto'];

    // Helper
    const store = {
        get: k => localStorage.getItem(k),
        set: (k, v) => localStorage.setItem(k, v)
    };

    // --- M3U8 LOGIC (Nearest Neighbor) ---
    const HLS = {
        isPl: (u, t) => u.includes('.m3u8') || t.includes('#EXT-X-STREAM-INF'),
        proc: (txt, q) => {
            if (q === 'Auto') return txt;

            const lines = txt.split(/\r?\n/);
            const vars = [];
            let header = "";
            let found = false;

            for (let i = 0; i < lines.length; i++) {
                const line = lines[i].trim();
                if (!line) continue;
                if (line.startsWith('#EXT-X-STREAM-INF:')) {
                    found = true;
                    const url = lines[i + 1]?.trim();
                    if (url) {
                        const res = /RESOLUTION=(\d+)x(\d+)/.exec(line);
                        const bw = /BANDWIDTH=(\d+)/.exec(line);
                        vars.push({
                            def: line, url,
                            p: res ? res[1] * res[2] : 0,
                            h: res ? +res[2] : 0,
                            bw: bw ? +bw[1] : 0
                        });
                        i++;
                    }
                } else if (!found) header += line + '\n';
            }

            if (!vars.length) return txt;
            vars.sort((a, b) => b.p - a.p || b.bw - a.bw);

            let sel;
            if (q === 'Best') sel = vars[0];
            else if (q === 'Worst') sel = vars[vars.length - 1];
            else {
                const target = parseInt(q, 10);
                sel = isNaN(target) ? vars[0] : vars.reduce((prev, curr) =>
                    Math.abs(curr.h - target) < Math.abs(prev.h - target) ? curr : prev
                );
            }
            return `${header}${sel.def}\n${sel.url}`;
        }
    };

    // --- NETWORK INTERCEPTOR ---
    class Interceptor {
        constructor(mgr) { this.mgr = mgr; this.reg = /^https:\/\/video\.twimg\.com\/.*\.m3u8/i; }
        enable() {
            const self = this;

            // XHR Interceptor
            const XHR = window.XMLHttpRequest;
            const origOpen = XHR.prototype.open;
            XHR.prototype.open = function (m, u) {
                if (typeof u === 'string' && self.reg.test(u)) {
                    this.addEventListener('readystatechange', function () {
                        if (this.readyState === 4 && this.status === 200 && (!this.responseType || this.responseType === 'text')) {
                            if (HLS.isPl(u, this.responseText)) {
                                const mod = HLS.proc(this.responseText, self.mgr.q);
                                Object.defineProperties(this, { response: { value: mod }, responseText: { value: mod } });
                                self.mgr.notify();
                            }
                        }
                    });
                }
                return origOpen.apply(this, arguments);
            };

            // Fetch Interceptor
            const origFetch = window.fetch;
            window.fetch = function (...args) {
                const url = (args[0] instanceof Request) ? args[0].url : args[0];
                if (typeof url === 'string' && self.reg.test(url)) {
                    return (async () => {
                        try {
                            const res = await origFetch.apply(this, args);
                            if (res.ok && (url.includes('.m3u8') || (res.headers.get('Content-Type') ?? '').includes('mpegurl'))) {
                                const txt = await res.clone().text();
                                if (HLS.isPl(url, txt)) {
                                    self.mgr.notify();
                                    const mod = HLS.proc(txt, self.mgr.q);
                                    const h = new Headers(res.headers);
                                    h.delete('Content-Encoding'); h.delete('Content-Length');
                                    return new Response(mod, { status: res.status, statusText: res.statusText, headers: h });
                                }
                            }
                            return res;
                        } catch (e) { return origFetch.apply(this, args); }
                    })();
                }
                return origFetch.apply(this, args);
            };
        }
    }

    // --- UI (CSS) ---
    class UI {
        constructor(mgr) { this.mgr = mgr; this.mini = store.get(KEYS.MIN) === '1'; }

        render() {
            if (!document.getElementById('vqfft-style')) {
                //
                const css = `.vqfft-box,.vqfft-box *{box-sizing:border-box}.vqfft-box{--bg:rgba(0,0,0,0.85);--txt:#e7e9ea;--acc:#1d9bf0;position:fixed;top:85px;right:20px;z-index:2147483647;background:var(--bg);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.15);border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.5);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.3;width:230px;user-select:none;transition:opacity .2s;opacity:.5}.vqfft-box:hover{opacity:1}.vqfft-box.mini{width:44px;height:44px;border-radius:50%;cursor:pointer;overflow:hidden;border:none}.vqfft-box.mini .bd,.vqfft-box.mini .tl{display:none}.vqfft-box.mini .hd{padding:0;justify-content:center;height:100%}.vqfft-box.mini::after{content:'';position:absolute;top:10px;right:10px;width:6px;height:6px;border-radius:50%;background:var(--acc);opacity:0}.vqfft-box.mini.locked::after{opacity:1}.hd{display:flex;align-items:center;padding:14px 18px;cursor:pointer}.hd:hover{background:rgba(255,255,255,.1)}.ic{width:20px;height:20px;color:#71767b;margin-right:12px;flex-shrink:0}.tl{font-weight:800;font-size:17px;flex:1}.it{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;cursor:pointer;font-size:15px;font-weight:500;transition:.15s}.it:hover{background:rgba(255,255,255,.1)}.ck,.sp{width:18px;height:18px;display:none}.it.active .ck{display:block;fill:var(--acc)}.it.active span{color:var(--acc);font-weight:700}.sp{border:2px solid rgba(29,155,240,.3);border-top-color:var(--acc);border-radius:50%;animation:s .6s linear infinite}.it.loading .ck{display:none}.it.loading .sp{display:block}@keyframes s{to{transform:rotate(360deg)}}.vqfft-t{position:fixed;bottom:40px;left:50%;transform:translate(-50%);background:var(--acc);color:#fff;padding:10px 20px;border-radius:99px;font-weight:700;animation:p .35s cubic-bezier(.21,1.02,.73,1)}@keyframes p{from{opacity:0;transform:translate(-50%,20px) scale(.9)}to{opacity:1;transform:translate(-50%,0)}}`;
                const s = document.createElement('style'); s.id = 'vqfft-style'; s.textContent = css;
                (document.head || document.documentElement).appendChild(s);
            }

            const div = document.createElement('div');
            this.el = div;
            this.updClass();

            div.innerHTML = `
                <div class="hd"><svg class="ic" viewBox="0 0 24 24"><path fill="currentColor" d="M10.54 1.75h2.92l.85 2.85c.98.34 1.9.84 2.73 1.48l2.84-1 .85 2.85-2.28 2.05c.1.51.15 1.04.15 1.57s-.05 1.06-.15 1.57l2.28 2.05-.85 2.85-2.84-1c-.83.64-1.75 1.14-2.73 1.48l-.85 2.85h-2.92l-.85-2.85c-.98-.34-1.9-.84-2.73-1.48l-2.84 1-.85-2.85 2.28-2.05c-.1-.51-.15-1.04-.15-1.57s.05-1.06.15-1.57L2.42 8.93l.85-2.85 2.84 1c.83-.64 1.75-1.14 2.73-1.48l.85-2.85zM12 16c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z"/></svg><span class="tl">Quality</span></div>
                <div class="bd">${Object.entries(LEVELS).map(([k, v]) => `<div class="it" data-v="${k}"><span>${v}</span><svg class="ck" viewBox="0 0 24 24"><path d="M9 20.42L2.79 14.21 5.62 11.38 9 14.77 18.88 4.88 21.71 7.71z"/></svg><div class="sp"></div></div>`).join('')}</div>
            `;

            document.body.appendChild(div);

            div.addEventListener('click', e => {
                e.stopPropagation();
                if (e.target.closest('.hd') || (this.mini && !e.target.closest('.it'))) {
                    this.mini = !this.mini; store.set(KEYS.MIN, this.mini ? '1' : '0'); this.updClass();
                } else if (e.target.closest('.it')) {
                    const it = e.target.closest('.it');
                    div.querySelectorAll('.it').forEach(i => i.classList.remove('loading'));
                    it.classList.add('loading');
                    this.mgr.set(it.dataset.v);
                }
            });
            this.updState();
        }

        updClass() { this.el.className = `vqfft-box ${this.mini?'mini':''} ${this.mgr.q!=='Auto'?'locked':''}`; }

        updState() {
            if(!this.el) return;
            this.el.querySelectorAll('.it').forEach(i => i.classList.toggle('active', i.dataset.v === this.mgr.q));
            this.updClass();
        }

        toast(msg) {
            const t = document.createElement('div'); t.className = 'vqfft-t'; t.textContent = msg;
            document.body.appendChild(t); setTimeout(() => t.remove(), 2500);
        }
    }

    // --- MANAGER ---
    class Manager {
        constructor() {
            this.q = store.get(KEYS.QUALITY) ?? 'Best';
            this.ui = new UI(this);
            this.net = new Interceptor(this);
        }
        start() {
            this.net.enable();
            document.addEventListener('keydown', e => { if (e.altKey && e.key.toLowerCase() === 'q') { e.preventDefault(); this.cycle(); } });
            const init = () => window.requestAnimationFrame(() => this.ui.render());
            document.readyState === 'complete' ? init() : window.addEventListener('DOMContentLoaded', init, { once: true });
        }
        cycle() {
            const n = ORDER[(ORDER.indexOf(this.q) + 1) % ORDER.length];
            this.ui.toast(`Switching: ${n}`); this.set(n);
        }
        set(v) {
            if (this.q !== v) { this.q = v; store.set(KEYS.QUALITY, v); setTimeout(() => location.reload(), 50); }
        }
        notify() {
            if (this.q !== 'Auto' && !this.n) { this.ui.toast(`${this.q} Locked`); this.n = 1; }
        }
    }

    new Manager().start();
})();