Transfer.it Tools Enhanced

Copy links for transfer.it with custom player support. Handles single and multi-file transfers.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Advertisement:

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

Advertisement:

// ==UserScript==
// @name         Transfer.it Tools Enhanced
// @namespace    https://tampermonkey.net/
// @version      1.0
// @description  Copy links for transfer.it with custom player support. Handles single and multi-file transfers.
// @author       pandamoon21
//
// @match        https://transfer.it/*
//
// @icon         https://www.google.com/s2/favicons?sz=64&domain=transfer.it
//
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_addElement
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      mega.co.nz
// @connect      *.mega.co.nz
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- CONFIGURATION ---
    const PRESETS = {
        potplayer: { name: "PotPlayer", scheme: "potplayer://" },
        vlc: { name: "VLC Media Player", scheme: "vlc://" },
        mpv: { name: "MPV", scheme: "mpv://" },
        kmplayer: { name: "KMPlayer", scheme: "kmplayer://" },
        iina: { name: "IINA (Mac)", scheme: "iina://" }
    };

    const MEGA_API_BASE = 'https://bt7.api.mega.co.nz';
    const ANTILEAK_HEADER = '/cs?id=418284579&v=3&lang=en&wcv=2.155.1163&domain=transferit&bb=3&bc=1';

    // Per-file caches
    const fileMap = new Map();
    const directUrlCache = new Map();
    let transferInfoCache = null;
    let fileListPromise = null;

    // --- PLAYER MANAGEMENT ---
    function getCurrentPlayer() {
        const key = GM_getValue('selectedPlayer', 'potplayer');
        if (key === 'custom') {
            return { key: 'custom', name: "Custom Player", scheme: GM_getValue('customPlayerScheme', '') };
        }
        return PRESETS[key] ? { key, ...PRESETS[key] } : { key: 'potplayer', ...PRESETS.potplayer };
    }

    function registerMenus() {
        const current = getCurrentPlayer();
        for (const [key, player] of Object.entries(PRESETS)) {
            const label = (current.key === key ? '✅ ' : '⚪ ') + player.name;
            GM_registerMenuCommand(`Change Player: ${label}`, () => {
                GM_setValue('selectedPlayer', key);
                location.reload();
            });
        }
        const customLabel = (current.key === 'custom' ? '✅ ' : '⚪ ') + "Custom Player";
        GM_registerMenuCommand(`Change Player: ${customLabel}`, () => {
            const savedScheme = GM_getValue('customPlayerScheme', '');
            const input = prompt("Input Video Player URI Scheme:\n(example: 'potplayer://' or 'mpc-be://')", savedScheme);
            if (input !== null) {
                const cleanInput = input.trim();
                if (cleanInput) {
                    GM_setValue('customPlayerScheme', cleanInput);
                    GM_setValue('selectedPlayer', 'custom');
                    location.reload();
                } else {
                    alert("Scheme can not be empty!");
                }
            }
        });
    }
    registerMenus();

    // --- MEGA API ---
    function megaApiPost(body, extraHeaders = {}) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: `${MEGA_API_BASE}/cs?`,
                headers: { "Content-Type": "text/plain;charset=UTF-8", "Accept": "*/*", "Origin": "https://transfer.it", "Referer": "https://transfer.it/", ...extraHeaders },
                data: JSON.stringify(body),
                onload: function(response) {
                    if (response.status === 200) {
                        try { resolve(JSON.parse(response.responseText)); }
                        catch (e) { reject(new Error('Failed to parse MEGA API response')); }
                    } else {
                        reject(new Error(`MEGA API error: ${response.status}`));
                    }
                },
                onerror: function() { reject(new Error('Network error calling MEGA API')); }
            });
        });
    }

    function isHashcashResponse(data) {
        if (!Array.isArray(data) || data.length === 0) return false;
        const first = data[0];
        return typeof first === 'object' && first !== null && (first.p !== undefined || first.hashcash !== undefined);
    }

    async function megaApiWithHashcash(body, xhValue, extraHeaders = {}) {
        let data = await megaApiPost(body, extraHeaders);
        if (isHashcashResponse(data)) {
            console.log('[TransferIt] Hashcash challenge, solving...');
            const hcData = await megaApiPost([{ a: 'hc', xh: xhValue }], { 'MEGA-Chrome-Antileak': ANTILEAK_HEADER });
            if (Array.isArray(hcData) && hcData[0]?.hc) {
                data = await megaApiPost(body, { ...extraHeaders, 'X-Hashcash': String(hcData[0].hc) });
            }
        }
        return data;
    }

    // --- URL HELPERS ---
    function getTransferId() {
        const match = window.location.pathname.match(/^\/t\/([a-zA-Z0-9_-]+)/);
        return match ? match[1] : null;
    }

    // --- TRANSFER INFO ---
    async function getTransferInfo(transferId) {
        if (transferInfoCache) return transferInfoCache;
        const data = await megaApiWithHashcash([{ a: 'xi', xh: transferId }], transferId);
        if (!Array.isArray(data) || !data[0] || typeof data[0] !== 'object') throw new Error('Invalid transfer info response');
        const info = data[0];

        // Single file: {zp, size}
        // Multi file: {t, z, m, size, se}
        transferInfoCache = {
            title: info.t ? atob(info.t.replace(/-/g, '+').replace(/_/g, '/')) : '',
            nonce: info.z || null,
            message: info.m ? atob(info.m.replace(/-/g, '+').replace(/_/g, '/')) : '',
            totalSize: Array.isArray(info.size) ? info.size[0] : 0,
            fileCount: Array.isArray(info.size) ? info.size[1] : 0,
            sender: info.se || '',
            expiry: info.zp || 0,
            isSingleFile: !info.t && !info.z
        };
        return transferInfoCache;
    }

    // --- FILE LIST ---
    async function getFileList(transferId) {
        const data = await megaApiWithHashcash(
            [{ a: 'f', c: 1, r: 1, xnc: 1 }],
            transferId,
            { 'MEGA-Chrome-Antileak': `${ANTILEAK_HEADER}&x=${transferId}` }
        );

        if (!Array.isArray(data) || !data[0]?.f) {
            throw new Error('Invalid file list response');
        }

        // Filter leaf files (t=0), sort by timestamp
        const files = data[0].f
            .filter(f => f.t === 0)
            .sort((a, b) => (a.ts || 0) - (b.ts || 0));

        return files.map(f => ({
            handle: f.h,
            size: f.s || 0,
            timestamp: f.ts || 0
        }));
    }

    // --- BUILD FILE MAP ---
    async function buildFileMap(transferId) {
        if (fileMap.size > 0) return fileMap;
        if (fileListPromise) return fileListPromise;

        fileListPromise = (async () => {
            const [apiFiles, domFilenames] = await Promise.all([
                getFileList(transferId),
                waitForDOMFilenames()
            ]);

            console.log(`[TransferIt] API files: ${apiFiles.length}, DOM filenames: ${domFilenames.length}`);

            const count = Math.min(apiFiles.length, domFilenames.length);
            for (let i = 0; i < count; i++) {
                const filename = domFilenames[i];
                const file = apiFiles[i];
                fileMap.set(filename, {
                    handle: file.handle,
                    size: file.size,
                    index: i
                });
            }

            return fileMap;
        })();

        return fileListPromise;
    }

    async function waitForDOMFilenames() {
        for (let i = 0; i < 20; i++) {
            const names = getDOMFilenames();
            if (names.length > 0) return names;
            await new Promise(r => setTimeout(r, 250));
        }
        return [];
    }

    function getDOMFilenames() {
        const results = [];

        // File manager view
        const fmItems = document.querySelectorAll('.js-fm-section:not(.hidden) .it-grid-item');
        fmItems.forEach(item => {
            const el = item.querySelector('span.md-font-size, span.pr-color');
            if (el) {
                const text = el.textContent.trim();
                if (text) results.push(text);
            }
        });
        if (results.length > 0) return results;

        // Link-ready view step-2
        const lrItems = document.querySelectorAll('.js-link-ready-section .body.step-2 .it-grid-item');
        lrItems.forEach(item => {
            const el = item.querySelector('span.md-font-size, span.pr-color');
            if (el) {
                const text = el.textContent.trim();
                if (text) results.push(text);
            }
        });
        if (results.length > 0) return results;

        // Fallback: video extensions
        const allEls = document.querySelectorAll('span, div.file-name, div.name');
        const seen = new Set();
        for (const el of allEls) {
            if (el.offsetParent === null && !el.closest('.js-fm-section, .js-link-ready-section, .js-ready-to-dl-section')) continue;
            const text = el.textContent.trim();
            if (text.match(/\.(mkv|mp4|avi|mov|webm|m4v|flv|wmv|mp3|wav|flac|aac|ogg|srt|ass|sub|zip|rar|7z)$/i) && text.length < 300 && !seen.has(text)) {
                seen.add(text);
                results.push(text);
            }
        }

        return results;
    }

    // --- DOWNLOAD URL ---
    function getDownloadUrl(transferId, fileHandle, filename) {
        const params = new URLSearchParams({ x: transferId, n: fileHandle, fn: filename });
        return `${MEGA_API_BASE}/cs/g?${params.toString()}`;
    }

    // --- ZIP DOWNLOAD URL ---
    function getZipDownloadUrl(transferId, nonce, folderName) {
        // Pattern: fn = folderName.zip (or transferID+nonce.zip if no folderName)
        const fn = folderName ? `${folderName}.zip` : `${transferId}${nonce}.zip`;
        const params = new URLSearchParams({ x: transferId, n: nonce, fn: fn });
        return `${MEGA_API_BASE}/cs/g?${params.toString()}`;
    }

    async function resolveZipDownloadUrl(transferId) {
        const info = await getTransferInfo(transferId);
        if (!info.nonce) {
            throw new Error('Transfer does not support zip download');
        }

        // Use transfer title as folder name (clean up invalid filename chars)
        let folderName = info.title || `${transferId}${info.nonce}`;
        folderName = folderName.replace(/[<>:"/\\|?*]/g, '_').trim();

        const zipUrl = getZipDownloadUrl(transferId, info.nonce, folderName);
        const directUrl = await fetchDirectLink(zipUrl);
        return { url: directUrl, filename: `${folderName}.zip` };
    }

    // --- FETCH DIRECT LINK ---
    function fetchDirectLink(downloadUrl) {
        return fetch(downloadUrl, {
            method: 'GET',
            redirect: 'manual',
            headers: {
                'Referer': 'https://transfer.it/',
                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
            }
        }).then(response => {
            if (response.type === 'opaqueredirect' || response.status === 0) {
                return fetchDirectLinkFallback(downloadUrl);
            }
            const location = response.headers.get('Location') || response.headers.get('location');
            if (location) return location;
            if (response.redirected && response.url && response.url !== downloadUrl) return response.url;
            throw new Error('No redirect Location found');
        }).catch(err => {
            console.warn('[TransferIt] fetch redirect:manual failed:', err);
            return fetchDirectLinkFallback(downloadUrl);
        });
    }

    function fetchDirectLinkFallback(downloadUrl) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: downloadUrl,
                headers: { "Referer": "https://transfer.it/", "Range": "bytes=0-0" },
                onload: function(response) {
                    const headers = response.responseHeaders || '';
                    const locationMatch = headers.match(/location:\s*(.+)/i);
                    if (locationMatch) { resolve(locationMatch[1].trim()); return; }
                    if (response.finalUrl && response.finalUrl !== downloadUrl) { resolve(response.finalUrl); return; }
                    reject(new Error('No redirect Location found'));
                },
                onerror: function() { reject(new Error('Network error fetching direct link')); }
            });
        });
    }

    // --- RESOLVE DIRECT LINK FOR A FILE ---
    async function resolveDirectLinkForFile(transferId, filename) {
        if (directUrlCache.has(filename)) {
            return directUrlCache.get(filename);
        }

        await buildFileMap(transferId);

        const fileInfo = fileMap.get(filename);
        if (!fileInfo) {
            throw new Error(`File "${filename}" not found. Available: ${[...fileMap.keys()].join(', ')}`);
        }

        const downloadUrl = getDownloadUrl(transferId, fileInfo.handle, filename);
        console.log(`[TransferIt] Fetching: "${filename}" (handle=${fileInfo.handle})`);

        const directUrl = await fetchDirectLink(downloadUrl);
        directUrlCache.set(filename, directUrl);
        console.log(`[TransferIt] Got URL for "${filename}"`);

        return directUrl;
    }

    // --- ICONS ---
    const ICONS = {
        play: '<svg viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>',
        copy: '<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>',
        download: '<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>',
        check: '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
        loading: '<svg viewBox="0 0 24 24"><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>'
    };

    // --- STYLES ---
    GM_addStyle(`
        .ti-file-actions {
            display: inline-flex !important;
            align-items: center !important;
            gap: 3px !important;
            margin-left: 4px !important;
            vertical-align: middle !important;
        }
        .ti-file-btn {
            display: inline-flex !important;
            align-items: center !important;
            justify-content: center !important;
            width: 28px !important;
            height: 28px !important;
            border: 1px solid rgba(255,255,255,0.2) !important;
            border-radius: 6px !important;
            background: rgba(255,255,255,0.1) !important;
            color: #ccc !important;
            cursor: pointer !important;
            padding: 0 !important;
            transition: all 0.15s ease !important;
            font-size: 0 !important;
            line-height: 0 !important;
        }
        .ti-file-btn:hover {
            background: rgba(255,255,255,0.2) !important;
            border-color: rgba(255,255,255,0.4) !important;
            color: #fff !important;
            transform: scale(1.15) !important;
        }
        .ti-file-btn svg { width: 14px !important; height: 14px !important; fill: currentColor !important; }
        .ti-file-btn.play-btn  { color: #4ade80 !important; border-color: rgba(74,222,128,0.3) !important; }
        .ti-file-btn.copy-btn  { color: #60a5fa !important; border-color: rgba(96,165,250,0.3) !important; }
        .ti-file-btn.play-btn:hover  { color: #86efac !important; border-color: rgba(74,222,128,0.6) !important; background: rgba(74,222,128,0.15) !important; }
        .ti-file-btn.copy-btn:hover  { color: #93c5fd !important; border-color: rgba(96,165,250,0.6) !important; background: rgba(96,165,250,0.15) !important; }
        .ti-file-btn.loading svg { animation: ti-spin 0.8s linear infinite !important; fill: #fbbf24 !important; }
        .ti-file-btn:disabled { opacity: 0.3 !important; cursor: not-allowed !important; transform: none !important; }

        .ti-fab {
            position: fixed !important;
            bottom: 24px !important;
            left: 24px !important;
            z-index: 2147483646 !important;
            width: 48px !important;
            height: 48px !important;
            border-radius: 50% !important;
            background: #3B82F6 !important;
            border: 2px solid rgba(255,255,255,0.15) !important;
            color: #fff !important;
            cursor: pointer !important;
            box-shadow: 0 4px 20px rgba(59,130,246,0.4), 0 2px 8px rgba(0,0,0,0.3) !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            transition: all 0.2s ease !important;
            padding: 0 !important;
        }
        .ti-fab:hover {
            background: #2563EB !important;
            transform: scale(1.1) !important;
            box-shadow: 0 6px 28px rgba(37,99,235,0.5), 0 2px 12px rgba(0,0,0,0.4) !important;
        }
        .ti-fab svg { width: 22px !important; height: 22px !important; fill: currentColor !important; }
        .ti-fab.panel-open {
            background: #60A5FA !important;
            box-shadow: 0 4px 20px rgba(96,165,250,0.5), 0 2px 8px rgba(0,0,0,0.3) !important;
        }

        .ti-panel {
            position: fixed !important;
            bottom: 82px !important;
            left: 24px !important;
            z-index: 2147483647 !important;
            background: #1F2937 !important;
            border-radius: 12px !important;
            padding: 20px !important;
            color: #F9FAFB !important;
            font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
            font-size: 13px !important;
            min-width: 340px !important;
            max-width: 460px !important;
            box-shadow: 0 8px 32px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1) !important;
        }
        .ti-panel.hidden { display: none !important; }

        .ti-header {
            display: flex !important;
            align-items: center !important;
            gap: 10px !important;
            margin-bottom: 12px !important;
            padding-bottom: 12px !important;
            border-bottom: 1px solid #374151 !important;
        }
        .ti-header svg { width: 20px !important; height: 20px !important; fill: #60A5FA !important; flex-shrink: 0 !important; }
        .ti-title { font-weight: 700 !important; font-size: 15px !important; color: #F9FAFB !important; }

        .ti-file-info {
            font-size: 13px !important;
            color: #D1D5DB !important;
            word-break: break-all !important;
            margin-bottom: 4px !important;
            line-height: 1.5 !important;
            font-weight: 600 !important;
        }
        .ti-file-meta {
            font-size: 11px !important;
            color: #9CA3AF !important;
            margin-bottom: 16px !important;
        }

        .ti-actions { display: flex !important; gap: 8px !important; flex-wrap: wrap !important; }
        .ti-btn {
            display: inline-flex !important;
            align-items: center !important;
            gap: 6px !important;
            padding: 10px 18px !important;
            border: none !important;
            border-radius: 8px !important;
            background: #374151 !important;
            color: #D1D5DB !important;
            cursor: pointer !important;
            font-size: 12px !important;
            font-weight: 600 !important;
            font-family: inherit !important;
            transition: all 0.2s ease !important;
            white-space: nowrap !important;
        }
        .ti-btn:hover {
            background: #4B5563 !important;
            color: #F9FAFB !important;
            transform: translateY(-1px) !important;
            box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important;
        }
        .ti-btn:active { transform: translateY(0) !important; }
        .ti-btn svg { width: 14px !important; height: 14px !important; fill: currentColor !important; flex-shrink: 0 !important; }
        .ti-btn.play-btn {
            background: #3B82F6 !important;
            color: #FFFFFF !important;
        }
        .ti-btn.play-btn:hover {
            background: #2563EB !important;
            box-shadow: 0 4px 12px rgba(59,130,246,0.4) !important;
        }
        .ti-btn.copy-btn:hover { color: #60A5FA !important; }
        .ti-btn.dl-btn:hover { color: #60A5FA !important; }
        .ti-btn.loading svg { animation: ti-spin 0.8s linear infinite !important; fill: #60A5FA !important; }
        .ti-btn:disabled { opacity: 0.4 !important; cursor: not-allowed !important; transform: none !important; }

        .ti-status {
            font-size: 11px !important;
            color: rgba(255,255,255,0.35) !important;
            margin-top: 10px !important;
            min-height: 16px !important;
        }
        .ti-status.success { color: #4ade80 !important; }
        .ti-status.error   { color: #f87171 !important; }

        @keyframes ti-spin { 100% { transform: rotate(360deg); } }
    `);

    // --- HELPERS ---
    function formatSize(bytes) {
        if (!bytes || bytes <= 0) return '';
        const units = ['B', 'KB', 'MB', 'GB', 'TB'];
        let i = 0, size = bytes;
        while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
        return `${size.toFixed(i > 0 ? 2 : 0)} ${units[i]}`;
    }

    function setPanelStatus(text, type) {
        const el = document.querySelector('.ti-status');
        if (el) { el.textContent = text; el.className = `ti-status ${type || ''}`; }
    }

    function setBtnLoading(btn, isLoading) {
        if (isLoading) {
            btn.dataset.originalIcon = btn.querySelector('svg')?.outerHTML || '';
            const svg = btn.querySelector('svg');
            if (svg) svg.outerHTML = ICONS.loading;
            btn.classList.add('loading');
            btn.disabled = true;
        } else {
            btn.classList.remove('loading');
            btn.disabled = false;
            const svg = btn.querySelector('svg');
            if (svg && btn.dataset.originalIcon) svg.outerHTML = btn.dataset.originalIcon;
        }
    }

    // --- ACTION HANDLER ---
    async function doAction(type, btn, filename) {
        const transferId = getTransferId();
        if (!transferId) return;

        const player = getCurrentPlayer();
        if (type === 'play' && player.key === 'custom' && !player.scheme) {
            alert("Please set custom scheme first via Tampermonkey menu.");
            return;
        }

        const hasFileList = document.querySelector('.js-fm-section:not(.hidden) .it-grid-item') ||
                           document.querySelector('.js-link-ready-section .body.step-2 .it-grid-item');

        if (!hasFileList) {
            const viewBtn = document.querySelector('.js-ready-to-dl-section .js-view-content');
            if (viewBtn && !viewBtn.classList.contains('hidden')) {
                viewBtn.click();
                setPanelStatus('Opening file list... click again!', '');
                return;
            }
            setPanelStatus('Click "View Content" first!', 'error');
            return;
        }

        setBtnLoading(btn, true);

        try {
            const url = await resolveDirectLinkForFile(transferId, filename);

            if (type === 'copy') {
                GM_setClipboard(url);
                setBtnLoading(btn, false);
                const svg = btn.querySelector('svg');
                if (svg) svg.outerHTML = ICONS.check;
                setPanelStatus(`Copied: ${filename}`, 'success');
                setTimeout(() => {
                    const s = btn.querySelector('svg');
                    if (s) s.outerHTML = ICONS.copy;
                    setPanelStatus('', '');
                }, 2000);
            } else if (type === 'play') {
                setBtnLoading(btn, false);
                window.location.href = `${player.scheme}${url}`;
            }
        } catch (err) {
            setBtnLoading(btn, false);
            console.error(`[TransferIt] ${type} error for "${filename}":`, err);
            setPanelStatus(`Error: ${err.message}`, 'error');
        }
    }

    // --- CREATE PER-FILE BUTTONS (Copy + Play only, no Download) ---
    function createFileActions(filename) {
        const group = document.createElement('div');
        group.className = 'ti-file-actions';
        group.dataset.filename = filename;

        const copyBtn = document.createElement('button');
        copyBtn.className = 'ti-file-btn copy-btn';
        copyBtn.title = `Copy link: ${filename}`;
        copyBtn.innerHTML = ICONS.copy;
        copyBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); doAction('copy', copyBtn, filename); });

        const playBtn = document.createElement('button');
        playBtn.className = 'ti-file-btn play-btn';
        playBtn.title = `Play: ${filename}`;
        playBtn.innerHTML = ICONS.play;
        playBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); doAction('play', playBtn, filename); });

        group.appendChild(copyBtn);
        group.appendChild(playBtn);

        return group;
    }

    // --- INJECT INLINE BUTTONS ---
    function injectInlineButtons() {
        const fmItems = document.querySelectorAll('.js-fm-section:not(.hidden) .it-grid-item');
        fmItems.forEach(item => {
            if (item.querySelector('.ti-file-actions')) return;
            const nameEl = item.querySelector('span.md-font-size, span.pr-color');
            if (!nameEl) return;
            const filename = nameEl.textContent.trim();
            if (!filename) return;
            const cols = item.querySelectorAll(':scope > .col');
            if (cols.length === 0) return;
            cols[cols.length - 1].appendChild(createFileActions(filename));
        });

        const lrItems = document.querySelectorAll('.js-link-ready-section .body.step-2 .it-grid-item');
        lrItems.forEach(item => {
            if (item.querySelector('.ti-file-actions')) return;
            const nameEl = item.querySelector('span.md-font-size, span.pr-color');
            if (!nameEl) return;
            const filename = nameEl.textContent.trim();
            if (!filename) return;
            const cols = item.querySelectorAll(':scope > .col');
            if (cols.length === 0) return;
            cols[cols.length - 1].appendChild(createFileActions(filename));
        });
    }

    // --- FLOATING PANEL ---
    let panelHost = null;

    function createPanelHost() {
        if (panelHost) return panelHost;

        panelHost = GM_addElement(document.documentElement, 'div', { id: 'ti-panel-host' });
        panelHost.style.cssText = 'position:fixed!important;z-index:2147483647!important;pointer-events:none!important;top:0!important;left:0!important;width:0!important;height:0!important;overflow:visible!important;';

        const fab = GM_addElement(panelHost, 'button', { class: 'ti-fab', title: 'Toggle Transfer.it Tools' });
        fab.innerHTML = ICONS.download;
        fab.style.pointerEvents = 'auto';
        fab.addEventListener('click', togglePanel);

        const panel = GM_addElement(panelHost, 'div', { class: 'ti-panel hidden' });
        panel.style.pointerEvents = 'auto';

        panel.innerHTML = `
            <div class="ti-header">
                <svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
                <span class="ti-title">Transfer.it Tools</span>
            </div>
            <div class="ti-file-info" id="ti-info">Loading...</div>
            <div class="ti-file-meta" id="ti-meta"></div>
            <div class="ti-actions" id="ti-actions"></div>
            <div class="ti-status" id="ti-status">Waiting...</div>
        `;

        return panelHost;
    }

    function togglePanel() {
        const host = createPanelHost();
        const panel = host.querySelector('.ti-panel');
        const fab = host.querySelector('.ti-fab');
        const isHidden = panel.classList.contains('hidden');

        if (isHidden) {
            panel.classList.remove('hidden');
            fab.classList.add('panel-open');
            fab.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
        } else {
            panel.classList.add('hidden');
            fab.classList.remove('panel-open');
            fab.innerHTML = ICONS.download;
        }
    }

    async function updatePanel(host) {
        const transferId = getTransferId();
        if (!transferId) return;

        try {
            const info = await getTransferInfo(transferId);
            const panel = host.querySelector('.ti-panel');
            if (!panel) return;

            const infoEl = panel.querySelector('#ti-info');
            const metaEl = panel.querySelector('#ti-meta');
            const actionsEl = panel.querySelector('#ti-actions');

            const isSingleFile = info.isSingleFile || info.fileCount === 1;

            if (infoEl) infoEl.textContent = info.title || (isSingleFile ? 'Single file transfer' : `${info.fileCount} files`);
            if (metaEl) metaEl.textContent = `${formatSize(info.totalSize)}${info.sender ? ' • from ' + info.sender : ''}`;

            // Build action buttons based on file count
            actionsEl.innerHTML = '';

            if (isSingleFile) {
                // Single file: Copy only
                actionsEl.innerHTML = `
                    <button class="ti-btn copy-btn" id="ti-copy" disabled>${ICONS.copy} Copy Link</button>
                `;

                panel.querySelector('#ti-copy').addEventListener('click', async function() {
                    const filenames = getDOMFilenames();
                    if (filenames.length === 0) {
                        setPanelStatus('No file found', 'error');
                        return;
                    }
                    await doAction('copy', this, filenames[0]);
                });
            } else {
                // Multi file: Copy All Links + Copy Zipped Link
                actionsEl.innerHTML = `
                    <button class="ti-btn copy-btn" id="ti-copy-all" disabled>${ICONS.copy} Copy All Links</button>
                    <button class="ti-btn copy-btn" id="ti-copy-zip" disabled>${ICONS.copy} Copy Zipped Link</button>
                `;

                panel.querySelector('#ti-copy-all').addEventListener('click', async function() {
                    const originalHtml = this.innerHTML;
                    setBtnLoading(this, true);
                    try {
                        const names = getDOMFilenames();
                        if (names.length === 0) {
                            throw new Error('No files found in DOM. Make sure file list is visible.');
                        }

                        const urls = [];
                        for (let i = 0; i < names.length; i++) {
                            try {
                                const url = await resolveDirectLinkForFile(transferId, names[i]);
                                urls.push(url);
                            } catch (fileErr) {
                                console.warn(`[TransferIt] Copy All: Failed for ${names[i]}:`, fileErr);
                            }
                        }

                        if (urls.length === 0) {
                            throw new Error('Failed to get any download URLs');
                        }

                        GM_setClipboard(urls.join('\n'));
                        setBtnLoading(this, false);
                        this.innerHTML = `${ICONS.check} Copied ${urls.length} links!`;
                        this.style.background = '#10B981';
                        this.style.color = '#FFFFFF';
                        setPanelStatus(`Copied ${urls.length} of ${names.length} links!`, 'success');
                        setTimeout(() => {
                            this.innerHTML = originalHtml;
                            this.style.background = '';
                            this.style.color = '';
                        }, 2000);
                    } catch (err) {
                        setBtnLoading(this, false);
                        setPanelStatus(`Error: ${err.message}`, 'error');
                    }
                });

                panel.querySelector('#ti-copy-zip').addEventListener('click', async function() {
                    const originalHtml = this.innerHTML;
                    setBtnLoading(this, true);
                    try {
                        const result = await resolveZipDownloadUrl(transferId);
                        GM_setClipboard(result.url);
                        setBtnLoading(this, false);
                        this.innerHTML = `${ICONS.check} Copied!`;
                        this.style.background = '#10B981';
                        this.style.color = '#FFFFFF';
                        setPanelStatus('Zip link copied to clipboard!', 'success');
                        setTimeout(() => {
                            this.innerHTML = originalHtml;
                            this.style.background = '';
                            this.style.color = '';
                        }, 2000);
                    } catch (err) {
                        setBtnLoading(this, false);
                        setPanelStatus(`Error: ${err.message}`, 'error');
                    }
                });
            }

            // Enable buttons
            actionsEl.querySelectorAll('.ti-btn').forEach(btn => btn.disabled = false);
            setPanelStatus(`Ready — ${getCurrentPlayer().name}`, 'success');
        } catch (err) {
            console.error('[TransferIt] Panel update error:', err);
            setPanelStatus(err.message, 'error');
        }
    }

    // --- INIT ---
    let isInitializing = false;
    let lastUrl = '';

    async function init() {
        const transferId = getTransferId();
        if (!transferId) {
            if (panelHost) { panelHost.remove(); panelHost = null; }
            return;
        }

        injectInlineButtons();

        const host = createPanelHost();

        if (!isInitializing && lastUrl !== window.location.href) {
            isInitializing = true;
            lastUrl = window.location.href;
            fileMap.clear();
            directUrlCache.clear();
            transferInfoCache = null;
            fileListPromise = null;

            await updatePanel(host);
            isInitializing = false;
        }
    }

    let retryCount = 0;
    const MAX_RETRIES = 40;
    const RETRY_MS = 500;

    function tryInit() {
        init();
        const transferId = getTransferId();
        if (transferId && retryCount < MAX_RETRIES) {
            retryCount++;
            setTimeout(tryInit, RETRY_MS);
        }
    }

    let lastPathname = window.location.pathname;
    setInterval(() => {
        if (window.location.pathname !== lastPathname) {
            lastPathname = window.location.pathname;
            retryCount = 0;
            isInitializing = false;
            fileMap.clear();
            directUrlCache.clear();
            transferInfoCache = null;
            fileListPromise = null;
            if (panelHost) { panelHost.remove(); panelHost = null; }
            tryInit();
        }
    }, 500);

    const observer = new MutationObserver(() => {
        if (getTransferId()) {
            injectInlineButtons();
            if (!panelHost && !isInitializing) {
                retryCount = 0;
                tryInit();
            }
        }
    });
    observer.observe(document.body || document.documentElement, { childList: true, subtree: true });

    tryInit();

})();