X Spaces + r

Addon for X Spaces with custom emojis, enhanced transcript including mute/unmute, hand raise/lower, mic invites, join/leave events, and speaker queuing.

目前為 2025-03-25 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         X Spaces + r 
// @namespace    Violentmonkey Scripts
// @version      1.91
// @description  Addon for X Spaces with custom emojis, enhanced transcript including mute/unmute, hand raise/lower, mic invites, join/leave events, and speaker queuing.
// @author       x.com/blankspeaker and x.com/PrestonHenshawX
// @match        https://twitter.com/*
// @match        https://x.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // [Previous unchanged code omitted for brevity: WebSocket, XMLHttpRequest, variables, fetchReplayUrl, debounce, getSpaceIdFromUrl, etc.]

    // [Keeping all other functions unchanged until formatTranscriptForDownload]

    async function formatTranscriptForDownload() {
        let transcriptText = '--- Space URLs ---\n';
        
        // Append live URL
        if (dynamicUrl) {
            transcriptText += `Live URL: ${dynamicUrl}\n`;
        } else {
            transcriptText += 'Live URL: Not available\n';
        }

        // Append replay URL (async fetch)
        try {
            const replayUrl = await fetchReplayUrl(dynamicUrl);
            transcriptText += `Replay URL: ${replayUrl}\n`;
        } catch (e) {
            transcriptText += 'Replay URL: Failed to generate\n';
        }

        transcriptText += '-----------------\n\n';

        let previousSpeaker = { username: '', handle: '' };
        const combinedData = [
            ...captionsData.map(item => ({ ...item, type: 'caption' })),
            ...emojiReactions.map(item => ({ ...item, type: 'emoji' }))
        ].sort((a, b) => a.timestamp - b.timestamp);

        combinedData.forEach((item, i) => {
            let { displayName, handle } = item;
            if (displayName === 'Unknown' && previousSpeaker.username) {
                displayName = previousSpeaker.username;
                handle = previousSpeaker.handle;
            }
            if (i > 0 && previousSpeaker.username !== displayName && item.type === 'caption') {
                transcriptText += '\n----------------------------------------\n';
            }
            if (item.type === 'caption') {
                transcriptText += `${displayName} ${handle}\n${item.text}\n\n`;
            } else if (item.type === 'emoji') {
                transcriptText += `${displayName} reacted with ${item.emoji}\n`;
            }
            previousSpeaker = { username: displayName, handle };
        });
        return transcriptText;
    }

    // [Unchanged functions: filterTranscript]

    function updateTranscriptPopup() {
        if (!transcriptPopup || transcriptPopup.style.display !== 'block') return;

        let queueContainer = transcriptPopup.querySelector('#queue-container');
        let searchContainer = transcriptPopup.querySelector('#search-container');
        let scrollArea = transcriptPopup.querySelector('#transcript-scrollable');
        let saveButton = transcriptPopup.querySelector('.save-button');
        let textSizeContainer = transcriptPopup.querySelector('.text-size-container');
        let systemToggleButton = transcriptPopup.querySelector('#system-toggle-button');
        let emojiToggleButton = transcriptPopup.querySelector('#emoji-toggle-button');
        let currentScrollTop = scrollArea ? scrollArea.scrollTop : 0;
        let wasAtBottom = scrollArea ? (scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight < 50) : true;

        let showEmojis = localStorage.getItem(STORAGE_KEYS.SHOW_EMOJIS) !== 'false';
        let showSystemMessages = localStorage.getItem(STORAGE_KEYS.SHOW_SYSTEM_MESSAGES) !== 'false';

        if (!queueContainer || !searchContainer || !scrollArea || !saveButton || !textSizeContainer || !systemToggleButton || !emojiToggleButton) {
            transcriptPopup.innerHTML = '';

            queueContainer = document.createElement('div');
            queueContainer.id = 'queue-container';
            queueContainer.style.marginBottom = '10px';
            transcriptPopup.appendChild(queueContainer);

            searchContainer = document.createElement('div');
            searchContainer.id = 'search-container';
            searchContainer.style.display = 'none';
            searchContainer.style.marginBottom = '5px';

            const searchInput = document.createElement('input');
            searchInput.type = 'text';
            searchInput.placeholder = 'Search transcript...';
            searchInput.style.width = '87%';
            searchInput.style.padding = '5px';
            searchInput.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
            searchInput.style.border = 'none';
            searchInput.style.borderRadius = '5px';
            searchInput.style.color = 'white';
            searchInput.style.fontSize = '14px';
            searchInput.addEventListener('input', (e) => {
                searchTerm = e.target.value.trim();
                updateTranscriptPopup();
            });

            searchContainer.appendChild(searchInput);
            transcriptPopup.appendChild(searchContainer);

            scrollArea = document.createElement('div');
            scrollArea.id = 'transcript-scrollable';
            scrollArea.style.flex = '1';
            scrollArea.style.overflowY = 'auto';
            scrollArea.style.maxHeight = '300px';

            const captionWrapper = document.createElement('div');
            captionWrapper.id = 'transcript-output';
            captionWrapper.style.color = '#e7e9ea';
            captionWrapper.style.fontFamily = 'Arial, sans-serif';
            captionWrapper.style.whiteSpace = 'pre-wrap';
            captionWrapper.style.fontSize = `${currentFontSize}px`;
            scrollArea.appendChild(captionWrapper);

            const controlsContainer = document.createElement('div');
            controlsContainer.style.display = 'flex';
            controlsContainer.style.alignItems = 'center';
            controlsContainer.style.justifyContent = 'space-between';
            controlsContainer.style.padding = '5px 0';
            controlsContainer.style.borderTop = '1px solid rgba(255, 255, 255, 0.3)';

            saveButton = document.createElement('div');
            saveButton.className = 'save-button';
            saveButton.textContent = '💾 Save Transcript';
            saveButton.style.color = '#1DA1F2';
            saveButton.style.fontSize = '14px';
            saveButton.style.cursor = 'pointer';
            saveButton.addEventListener('click', async () => { // Updated to async
                saveButton.textContent = '💾 Saving...'; // Feedback during async operation
                const transcriptContent = await formatTranscriptForDownload();
                const blob = new Blob([transcriptContent], { type: 'text/plain' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = `transcript_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
                saveButton.textContent = '💾 Save Transcript'; // Reset button text
            });
            saveButton.addEventListener('mouseover', () => saveButton.style.color = '#FF9800');
            saveButton.addEventListener('mouseout', () => saveButton.style.color = '#1DA1F2');

            textSizeContainer = document.createElement('div');
            textSizeContainer.className = 'text-size-container';
            textSizeContainer.style.display = 'flex';
            textSizeContainer.style.alignItems = 'center';

            systemToggleButton = document.createElement('span');
            systemToggleButton.id = 'system-toggle-button';
            systemToggleButton.style.position = 'relative';
            systemToggleButton.style.fontSize = '14px';
            systemToggleButton.style.cursor = 'pointer';
            systemToggleButton.style.marginRight = '5px';
            systemToggleButton.style.width = '14px';
            systemToggleButton.style.height = '14px';
            systemToggleButton.style.display = 'inline-flex';
            systemToggleButton.style.alignItems = 'center';
            systemToggleButton.style.justifyContent = 'center';
            systemToggleButton.title = 'Toggle System Messages';
            systemToggleButton.innerHTML = '📢';

            const systemNotAllowedOverlay = document.createElement('span');
            systemNotAllowedOverlay.style.position = 'absolute';
            systemNotAllowedOverlay.style.width = '14px';
            systemNotAllowedOverlay.style.height = '14px';
            systemNotAllowedOverlay.style.border = '2px solid red';
            systemNotAllowedOverlay.style.borderRadius = '50%';
            systemNotAllowedOverlay.style.transform = 'rotate(45deg)';
            systemNotAllowedOverlay.style.background = 'transparent';
            systemNotAllowedOverlay.style.display = showSystemMessages ? 'none' : 'block';

            const systemSlash = document.createElement('span');
            systemSlash.style.position = 'absolute';
            systemSlash.style.width = '2px';
            systemSlash.style.height = '18px';
            systemSlash.style.background = 'red';
            systemSlash.style.transform = 'rotate(-45deg)';
            systemSlash.style.top = '-2px';
            systemSlash.style.left = '6px';
            systemNotAllowedOverlay.appendChild(systemSlash);

            systemToggleButton.appendChild(systemNotAllowedOverlay);

            systemToggleButton.addEventListener('click', () => {
                showSystemMessages = !showSystemMessages;
                systemNotAllowedOverlay.style.display = showSystemMessages ? 'none' : 'block';
                localStorage.setItem(STORAGE_KEYS.SHOW_SYSTEM_MESSAGES, showSystemMessages);
                updateTranscriptPopup();
            });

            emojiToggleButton = document.createElement('span');
            emojiToggleButton.id = 'emoji-toggle-button';
            emojiToggleButton.style.position = 'relative';
            emojiToggleButton.style.fontSize = '14px';
            emojiToggleButton.style.cursor = 'pointer';
            emojiToggleButton.style.marginRight = '5px';
            emojiToggleButton.style.width = '14px';
            emojiToggleButton.style.height = '14px';
            emojiToggleButton.style.display = 'inline-flex';
            emojiToggleButton.style.alignItems = 'center';
            emojiToggleButton.style.justifyContent = 'center';
            emojiToggleButton.title = 'Toggle Emoji Capturing';
            emojiToggleButton.innerHTML = '🙂';

            const emojiNotAllowedOverlay = document.createElement('span');
            emojiNotAllowedOverlay.style.position = 'absolute';
            emojiNotAllowedOverlay.style.width = '14px';
            emojiNotAllowedOverlay.style.height = '14px';
            emojiNotAllowedOverlay.style.border = '2px solid red';
            emojiNotAllowedOverlay.style.borderRadius = '50%';
            emojiNotAllowedOverlay.style.transform = 'rotate(45deg)';
            emojiNotAllowedOverlay.style.background = 'transparent';
            emojiNotAllowedOverlay.style.display = showEmojis ? 'none' : 'block';

            const emojiSlash = document.createElement('span');
            emojiSlash.style.position = 'absolute';
            emojiSlash.style.width = '2px';
            emojiSlash.style.height = '18px';
            emojiSlash.style.background = 'red';
            emojiSlash.style.transform = 'rotate(-45deg)';
            emojiSlash.style.top = '-2px';
            emojiSlash.style.left = '6px';
            emojiNotAllowedOverlay.appendChild(emojiSlash);

            emojiToggleButton.appendChild(emojiNotAllowedOverlay);

            emojiToggleButton.addEventListener('click', () => {
                showEmojis = !showEmojis;
                emojiNotAllowedOverlay.style.display = showEmojis ? 'none' : 'block';
                localStorage.setItem(STORAGE_KEYS.SHOW_EMOJIS, showEmojis);
                updateTranscriptPopup();
            });

            const magnifierEmoji = document.createElement('span');
            magnifierEmoji.textContent = '🔍';
            magnifierEmoji.style.marginRight = '5px';
            magnifierEmoji.style.fontSize = '14px';
            magnifierEmoji.style.cursor = 'pointer';
            magnifierEmoji.title = 'Search transcript';
            magnifierEmoji.addEventListener('click', () => {
                searchContainer.style.display = searchContainer.style.display === 'none' ? 'block' : 'none';
                if (searchContainer.style.display === 'block') searchInput.focus();
                else {
                    searchTerm = '';
                    searchInput.value = '';
                    updateTranscriptPopup();
                }
            });

            const textSizeSlider = document.createElement('input');
            textSizeSlider.type = 'range';
            textSizeSlider.min = '12';
            textSizeSlider.max = '18';
            textSizeSlider.value = currentFontSize;
            textSizeSlider.style.width = '50px';
            textSizeSlider.style.cursor = 'pointer';
            textSizeSlider.title = 'Adjust transcript text size';
            textSizeSlider.addEventListener('input', () => {
                currentFontSize = parseInt(textSizeSlider.value, 10);
                const captionWrapper = transcriptPopup.querySelector('#transcript-output');
                if (captionWrapper) captionWrapper.style.fontSize = `${currentFontSize}px`;
                localStorage.setItem('xSpacesCustomReactions_textSize', currentFontSize);
            });

            const savedTextSize = localStorage.getItem('xSpacesCustomReactions_textSize');
            if (savedTextSize) {
                currentFontSize = parseInt(savedTextSize, 10);
                textSizeSlider.value = currentFontSize;
            }

            textSizeContainer.appendChild(systemToggleButton);
            textSizeContainer.appendChild(emojiToggleButton);
            textSizeContainer.appendChild(magnifierEmoji);
            textSizeContainer.appendChild(textSizeSlider);

            controlsContainer.appendChild(saveButton);
            controlsContainer.appendChild(textSizeContainer);

            transcriptPopup.appendChild(queueContainer);
            transcriptPopup.appendChild(searchContainer);
            transcriptPopup.appendChild(scrollArea);
            transcriptPopup.appendChild(controlsContainer);
        }

        const { captions: filteredCaptions, emojis: filteredEmojis } = filterTranscript(captionsData, emojiReactions, searchTerm);
        const combinedData = [
            ...filteredCaptions.map(item => ({ ...item, type: 'caption' })),
            ...(showEmojis ? filteredEmojis.map(item => ({ ...item, type: 'emoji' })) : [])
        ].sort((a, b) => a.timestamp - b.timestamp);

        // Find the previous speaker before the last 200 entries
        let previousSpeaker = lastSpeaker || { username: '', handle: '' };
        if (combinedData.length > 200) {
            for (let i = combinedData.length - 201; i >= 0; i--) {
                if (combinedData[i].type === 'caption') {
                    previousSpeaker = { username: combinedData[i].displayName, handle: combinedData[i].handle };
                    break;
                }
            }
        }

        // Limit to the last 200 entries
        const recentData = combinedData.slice(-200);

        // Group consecutive emojis within the 200 entries
        let emojiGroups = [];
        let currentGroup = null;
        recentData.forEach(item => {
            if (item.type === 'caption') {
                if (currentGroup) {
                    emojiGroups.push(currentGroup);
                    currentGroup = null;
                }
                emojiGroups.push(item);
            } else if (item.type === 'emoji' && showEmojis) {
                if (currentGroup && currentGroup.displayName === item.displayName && currentGroup.emoji === item.emoji &&
                    Math.abs(item.timestamp - currentGroup.items[currentGroup.items.length - 1].timestamp) < 50) {
                    currentGroup.count++;
                    currentGroup.items.push(item);
                } else {
                    if (currentGroup) emojiGroups.push(currentGroup);
                    currentGroup = { displayName: item.displayName, emoji: item.emoji, count: 1, items: [item] };
                }
            }
        });
        if (currentGroup) emojiGroups.push(currentGroup);

        // Build the HTML string
        let html = '';
        if (combinedData.length > 200) {
            html += '<div style="color: #FFD700; font-size: 12px; margin-bottom: 10px;">Showing the last 200 lines. Save transcript to see the full conversation.</div>';
        }
        emojiGroups.forEach((group, i) => {
            if (group.type === 'caption') {
                let { displayName, handle, text } = group;
                if (displayName === 'Unknown' && previousSpeaker.username) {
                    displayName = previousSpeaker.username;
                    handle = previousSpeaker.handle;
                }
                if (i > 0 && previousSpeaker.username !== displayName) {
                    html += '<div style="border-top: 1px solid rgba(255, 255, 255, 0.3); margin: 5px 0;"></div>';
                }
                html += `<span style="font-size: ${currentFontSize}px; color: #1DA1F2">${displayName}</span> ` +
                        `<span style="font-size: ${currentFontSize}px; color: #808080">${handle}</span><br>` +
                        `<span style="font-size: ${currentFontSize}px; color: ${displayName === 'System' ? '#FF4500' : '#FFFFFF'}">${text}</span><br><br>`;
                previousSpeaker = { username: displayName, handle };
            } else if (showEmojis) {
                let { displayName, emoji, count } = group;
                if (displayName === 'Unknown' && previousSpeaker.username) {
                    displayName = previousSpeaker.username;
                }
                const countText = count > 1 ? ` <span style="font-size: ${currentFontSize}px; color: #FFD700">x${count}</span>` : '';
                html += `<span style="font-size: ${currentFontSize}px; color: #FFD700">${displayName}</span> ` +
                        `<span style="font-size: ${currentFontSize}px; color: #FFFFFF">reacted with ${emoji}${countText}</span><br>`;
                previousSpeaker = { username: displayName, handle: group.items[0].handle };
            }
        });

        // Update the DOM once
        const captionWrapper = scrollArea.querySelector('#transcript-output');
        if (captionWrapper) {
            captionWrapper.innerHTML = html;
            lastSpeaker = previousSpeaker;

            // Maintain scroll position
            if (wasAtBottom && !searchTerm) scrollArea.scrollTop = scrollArea.scrollHeight;
            else scrollArea.scrollTop = currentScrollTop;

            scrollArea.onscroll = () => {
                isUserScrolledUp = scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight > 50;
            };
        }

        if (handQueuePopup && handQueuePopup.style.display === 'block') {
            updateHandQueueContent(handQueuePopup.querySelector('#hand-queue-content'));
        }
    }

    // [Unchanged functions: updateHandQueueContent, init, etc. omitted for brevity]

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();