Greasy Fork is available in English.

Twitter/X Layout Modifier

Remove right sidebar, expand middle column, and inject custom vertical image stack with sizing fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitter/X Layout Modifier
// @namespace    http://tampermonkey.net/
// @license MIT
// @version      1.4.5
// @description  Remove right sidebar, expand middle column, and inject custom vertical image stack with sizing fixes
// @author       maye9999
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        none
// ==/UserScript==
(function () {
    'use strict';

    // 1. CSS for Structural Layout Changes (Sidebar & Column Width)
    const style = document.createElement('style');
    style.innerHTML = `
        :root {
            --ag-max-height: 85vh;       /* [EDIT HERE] Global Max Height for media */
            --ag-layout-width: 100%;     /* [EDIT HERE] Global Column Width (set to 100% for full monitor width) */
            --ag-media-max-width: 100%;  /* [EDIT HERE] Max width for single images (e.g. 1200px) */
        }
        
        /* Remove the right sidebar */
        [data-testid="sidebarColumn"] {
            display: none !important;
        }

        /* Expand the middle column (primaryColumn) */
        [data-testid="primaryColumn"] {
            max-width: var(--ag-layout-width) !important;
            width: var(--ag-layout-width) !important;
            flex-basis: auto !important;
        }

        /* Ensure parent containers allow expansion */
        div:has(> [data-testid="primaryColumn"]) {
             max-width: var(--ag-layout-width) !important;
             width: var(--ag-layout-width) !important;
        }
        
        .r-1ye8kvj {
            max-width: var(--ag-layout-width) !important;
        }
        
        /* Custom class for our injected container */
        .ag-custom-media-stack {
            display: flex;
            flex-direction: row;   /* Allow horizontal flow */
            flex-wrap: wrap;       /* Allow wrapping */
            width: 100%;
            margin-top: 10px;
            gap: 12px;             /* Space between images (both horizontal and vertical) */
            align-items: flex-start;
        }
        
        /* Image styling */
        .ag-custom-media-stack img {
            width: auto;
            max-width: var(--ag-media-max-width);       
            max-height: var(--ag-max-height);
            height: auto;
            border-radius: 12px;
            display: block;
            border: 1px solid rgba(255,255,255,0.1);
            cursor: pointer;
            object-fit: contain;
            flex: 0 1 auto;        /* Allow image to be its natural size, but shrink if needed */
        }
        
        /* Lightbox-like effect on click (optional simple zoom) */
        .ag-custom-media-stack img:active {
            transform: scale(1.02);
            transition: transform 0.1s;
        }
        
        /* STRICT Video Scaling Rules */
        .ag-custom-media-stack video {
            object-fit: contain !important;
            background-color: black !important;
        }
        
        .ag-custom-media-stack div[style*="background-image"] {
            background-size: contain !important;
            background-repeat: no-repeat !important;
            background-position: center !important;
        }
    `;
    document.head.appendChild(style);



    // Helper: Hijack Video Pause
    function hijackVideo(videoEl, wrapper, getLastInteraction, getLastPlayTime) {
        if (videoEl.dataset.agHijacked) return;
        videoEl.dataset.agHijacked = 'true';

        console.log('[AntiGravity] Hijacking video element:', videoEl);

        // Save original pause functionality
        const originalPause = videoEl.pause.bind(videoEl);

        // Overwrite pause function
        videoEl.pause = function (arg) {
            const now = Date.now();
            const timeSinceInteraction = now - getLastInteraction();
            const timeSincePlay = now - getLastPlayTime();

            // 1. Is it a user pause? (Recent interaction < 500ms)
            if (timeSinceInteraction < 500) {
                return originalPause(arg);
            }

            // 2. Is it a "First Click" conflict? 
            if (timeSincePlay < 500) {
                console.log('[AntiGravity] Blocked auto-pause (First-Click Guard)');
                return;
            }

            // 3. Is it an auto-pause? Check visibility.
            const rect = wrapper.getBoundingClientRect();
            const isVisible = (
                rect.top < window.innerHeight &&
                rect.bottom > 0
            );

            if (isVisible) {
                console.log('[AntiGravity] Blocked auto-pause via hijack');
                return;
            } else {
                return originalPause(arg);
            }
        };

        // Ensure styles
        videoEl.style.objectFit = 'contain';
        videoEl.style.backgroundColor = 'black';
    }


    // 2. JavaScript for Custom Layout Injection
    function processTweets() {
        // Find all tweets or photo-containing elements
        const photos = document.querySelectorAll('[data-testid="tweetPhoto"]');

        // Map to store unique root containers we've found in this pass
        // Map<Element, Array<{type: 'img'|'video', src?: string, node?: Element, aspectRatio?: number}>>
        const rootsToProcess = new Map();

        photos.forEach(photo => {
            if (photo.hasAttribute('data-ag-processed')) return;

            // Determine content type: Image or Video?
            // Videos are often nested inside tweetPhoto or siblings in the same grid.
            // Actually, for mixed grids, Twitter might use tweetPhoto for images and something else for video, 
            // OR reuse tweetPhoto but put a video player inside.
            // Based on profile.html, videoPlayer is inside placementTracking inside tweetPhoto (or similar structure).

            let itemData = null;
            const videoComponent = photo.querySelector('[data-testid="videoPlayer"]');

            if (videoComponent) {
                // It's a video!
                // We want to grab the whole player to preserve functionality.
                // We also need the aspect ratio to size it correctly.
                // Twitter usually puts a padding-bottom on a child div for aspect ratio.
                let ratio = 0.5625; // Default 16:9
                const spacer = videoComponent.querySelector('div[style*="padding-bottom"]');
                if (spacer) {
                    const pb = spacer.style.paddingBottom;
                    if (pb && pb.includes('%')) {
                        ratio = parseFloat(pb) / 100;
                    }
                }

                itemData = {
                    type: 'video',
                    node: videoComponent, // We will move this node
                    aspectRatio: ratio
                };
            } else {
                // It's an image
                const img = photo.querySelector('img');
                if (!img) return; // Skip if no image found

                let src = img.src;
                if (src.includes('&name=')) {
                    src = src.replace(/&name=[a-z0-9]+/, '&name=large');
                }
                itemData = {
                    type: 'img',
                    src: src
                };
            }

            // Find root container logic (same as before)
            const tweet = photo.closest('[data-testid="tweet"]');
            if (tweet) {
                const allImagesInTweet = Array.from(tweet.querySelectorAll('[data-testid="tweetPhoto"]'));
                if (allImagesInTweet.length > 0) {
                    let ancestor = allImagesInTweet[0];
                    if (allImagesInTweet.length > 1) {
                        const parents = new Set();
                        let p = ancestor;
                        while (p && p !== tweet) {
                            parents.add(p);
                            p = p.parentElement;
                        }
                        let p2 = allImagesInTweet[1].parentElement;
                        while (p2 && p2 !== tweet) {
                            if (parents.has(p2)) {
                                ancestor = p2;
                                break;
                            }
                            p2 = p2.parentElement;
                        }
                    }

                    let contentRoot = ancestor;
                    while (contentRoot.parentElement && contentRoot.parentElement !== tweet) {
                        if (contentRoot.parentElement.querySelector('[data-testid="tweetText"]')) {
                            break;
                        }
                        contentRoot = contentRoot.parentElement;
                    }

                    if (!rootsToProcess.has(contentRoot)) {
                        rootsToProcess.set(contentRoot, []);
                    }

                    const list = rootsToProcess.get(contentRoot);
                    // Avoid duplicates
                    if (itemData.type === 'img') {
                        if (!list.some(i => i.type === 'img' && i.src === itemData.src)) {
                            list.push(itemData);
                        }
                    } else {
                        // For video, check reference equality or something? 
                        // Just check if we already added this node
                        if (!list.some(i => i.type === 'video' && i.node === itemData.node)) {
                            list.push(itemData);
                        }
                    }

                    photo.setAttribute('data-ag-processed', 'true');
                }
            }
        });

        // Now process the roots
        rootsToProcess.forEach((items, root) => {
            if (root.getAttribute('data-ag-replaced')) return;

            // Hide Root
            root.style.display = "none";
            root.setAttribute('data-ag-replaced', 'true');

            // Inject Custom Stack
            const stack = document.createElement('div');
            stack.className = 'ag-custom-media-stack';

            items.forEach((item) => {
                if (item.type === 'img') {
                    const img = document.createElement('img');
                    img.src = item.src;
                    img.onclick = (e) => {
                        e.stopPropagation();
                        window.open(item.src, '_blank');
                    };
                    stack.appendChild(img);
                } else if (item.type === 'video') {
                    const wrapper = document.createElement('div');
                    wrapper.style.cssText = `
                        flex: 0 1 auto;
                        width: 100%;
                        position: relative;
                        border-radius: 12px;
                        overflow: hidden;
                        border: 1px solid rgba(255,255,255,0.1);
                     `;

                    // Calculate Max Width to respect 85vh constraint
                    // If Height = Width * Ratio, and Height_Max = 85vh
                    // Then Width_Max = 85vh / Ratio
                    if (item.aspectRatio > 0) {
                        // 85vh in pixels (approx) or use calc
                        // Using calc is safer: calc(85vh / ratio)
                        // But ratio is a number. Let's use JS to set a clean max-width percentage or pixel value if possible, 
                        // or just standard CSS max-height won't work on padding-hack boxes.

                        // We can set 'max-width' on the wrapper.
                        // 1 / ratio = inverse ratio (width/height)
                        // max-width = 85vh * (1/ratio)

                        const inverseRatio = 1 / item.aspectRatio;
                        wrapper.style.maxWidth = `calc(var(--ag-max-height) * ${inverseRatio})`;
                    }

                    // Move the node
                    wrapper.appendChild(item.node);

                    // Ensure the video player fills our wrapper and is visible
                    item.node.style.cssText = `
                        position: relative !important;
                        top: auto !important;
                        left: auto !important;
                        right: auto !important;
                        bottom: auto !important;
                        width: 100% !important;
                        height: auto !important;
                        display: block !important;
                        max-width: none !important;
                        opacity: 1 !important;
                        transform: none !important;
                     `;

                    // --- ROBUST ANTI-PAUSE (METHOD HIJACKING) ---

                    // TRACK INTERACTIONS
                    let lastUserInteraction = 0;
                    let lastPlayTime = 0;
                    const markInteraction = () => lastUserInteraction = Date.now();
                    const markPlay = () => lastPlayTime = Date.now();
                    const getLastInteraction = () => lastUserInteraction;
                    const getLastPlayTime = () => lastPlayTime;

                    item.node.addEventListener('mousedown', markInteraction, true);
                    item.node.addEventListener('keydown', markInteraction, true);
                    item.node.addEventListener('touchstart', markInteraction, true);
                    item.node.addEventListener('click', markInteraction, true);
                    item.node.addEventListener('play', markPlay, true);

                    // INITIAL CHECK
                    const existingVideo = item.node.querySelector('video');
                    if (existingVideo) {
                        hijackVideo(existingVideo, wrapper, getLastInteraction, getLastPlayTime);
                    }

                    // OBSERVER for Lazy Loaded Videos
                    const videoObserver = new MutationObserver((mutations) => {
                        mutations.forEach(m => {
                            m.addedNodes.forEach(node => {
                                if (node.nodeName === 'VIDEO') {
                                    hijackVideo(node, wrapper, getLastInteraction, getLastPlayTime);
                                } else if (node.querySelectorAll) {
                                    const v = node.querySelector('video');
                                    if (v) hijackVideo(v, wrapper, getLastInteraction, getLastPlayTime);
                                }
                            });
                        });
                    });
                    videoObserver.observe(item.node, { childList: true, subtree: true });

                    stack.appendChild(wrapper);
                }
            });

            root.insertAdjacentElement('afterend', stack);
        });
    }

    // 3. Observe the DOM for new tweets
    const observer = new MutationObserver((mutations) => {
        processTweets();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    // Initial run
    setTimeout(processTweets, 500);
    setInterval(processTweets, 2000);

})();