Mucklet Media Embedder Ultra

Embeds YouTube, Spotify, SoundCloud, and direct media links in Mucklet with advanced hash directives.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Mucklet Media Embedder Ultra
// @name:de      Mucklet Medien-Einbetter Ultra
// @namespace    https://forum.wolfery.com/u/felinex/
// @version      1.5
// @description  Embeds YouTube, Spotify, SoundCloud, and direct media links in Mucklet with advanced hash directives.
// @description:de Bettet YouTube, Spotify, SoundCloud und direkte Medien-Links in Mucklet mit erweiterten Hash-Direktiven ein.
// @icon         https://static.f-list.net/images/eicon/wolfery.png
// @license      All Rights Reserved
// @author       Felinex Gloomfort
// @match        *://*.mucklet.com/*
// @match        *://wolfery.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const STYLE_ID = 'mme-ultra-style';
    const MODAL_ID = 'mme-ultra-modal';

    // Chat scope selectors / Selektoren für Chat-Bereiche
    const CHAT_SCOPE_SELECTORS = [
        '[role="log"]',
        '.charlog',
        '.chatlog',
        '.chatLog',
        '.messages',
        '.message',
        '.msg',
        '.logEntry',
        '.log-entry',
        '.logentry',
        '.chat',
        '[class*="charlog"]',
        '[class*="chatlog"]',
        '[class*="message"]',
        '[class*="msg"]',
        '[class*="speech"]',
        '[class*="ooc"]',
        '[class*="ic"]'
    ];

    // Excluded UI areas / Ausgeschlossene UI-Bereiche
    const EXCLUDED_SCOPE_SELECTORS = [
        'nav',
        'header',
        'footer',
        'aside',
        'form',
        'button',
        '[role="dialog"]',
        '[role="navigation"]',
        '.menu',
        '.dropdown',
        '.tooltip',
        '.modal',
        '.popup'
    ].join(',');

    injectStyle();
    const modalApi = createModal();

    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                scanForLinks(node);
            }
        }
    });

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

    function injectStyle() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = `
            .mme-embed-holder {
                display: block;
                margin-top: 6px;
                margin-bottom: 6px;
                max-width: 100%;
                overflow: hidden;
            }

            .mme-embed-box {
                display: inline-block;
                max-width: 100%;
                border-radius: 8px;
                overflow: hidden;
                vertical-align: top;
                background: rgba(0, 0, 0, 0.14);
            }

            .mme-embed-box iframe,
            .mme-embed-box img,
            .mme-embed-box video,
            .mme-embed-box audio {
                display: block;
                max-width: 100%;
                border: none;
            }

            .mme-inline-emot {
                display: inline-block;
                height: 1em;
                width: auto;
                max-height: 1em;
                vertical-align: baseline;
                object-fit: contain;
            }

            .mme-popup-overlay {
                position: fixed;
                inset: 0;
                z-index: 2147483647;
                display: none;
                align-items: center;
                justify-content: center;
                padding: 24px;
                box-sizing: border-box;
                background: rgba(0, 0, 0, 0.82);
            }

            .mme-popup-overlay.mme-open {
                display: flex;
            }

            .mme-popup-dialog {
                position: relative;
                width: auto;
                max-width: min(92vw, 1100px);
                max-height: 90vh;
                overflow: auto;
                border-radius: 10px;
                padding: 12px;
                box-sizing: border-box;
                background: rgba(20, 20, 20, 0.97);
            }

            .mme-popup-close {
                display: block;
                margin-left: auto;
                margin-bottom: 10px;
                cursor: pointer;
                border: none;
                border-radius: 6px;
                padding: 6px 10px;
                color: #fff;
                background: rgba(255,255,255,0.16);
            }

            .mme-popup-content iframe,
            .mme-popup-content img,
            .mme-popup-content video,
            .mme-popup-content audio {
                display: block;
                max-width: 100%;
            }

            .mme-popup-content video,
            .mme-popup-content img {
                max-height: 80vh;
            }
        `;
        document.head.appendChild(style);
    }

    function createModal() {
        let overlay = document.getElementById(MODAL_ID);
        if (overlay) {
            return {
                open(node) {
                    const content = overlay.querySelector('.mme-popup-content');
                    content.innerHTML = '';
                    content.appendChild(node);
                    overlay.classList.add('mme-open');
                },
                close() {
                    const content = overlay.querySelector('.mme-popup-content');
                    content.innerHTML = '';
                    overlay.classList.remove('mme-open');
                }
            };
        }

        overlay = document.createElement('div');
        overlay.id = MODAL_ID;
        overlay.className = 'mme-popup-overlay';

        const dialog = document.createElement('div');
        dialog.className = 'mme-popup-dialog';

        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.className = 'mme-popup-close';
        closeBtn.textContent = 'Close / Schließen';

        const content = document.createElement('div');
        content.className = 'mme-popup-content';

        closeBtn.addEventListener('click', closeModal);
        overlay.addEventListener('click', (event) => {
            if (event.target === overlay) closeModal();
        });

        document.addEventListener('keydown', (event) => {
            if (event.key === 'Escape' && overlay.classList.contains('mme-open')) {
                closeModal();
            }
        });

        dialog.appendChild(closeBtn);
        dialog.appendChild(content);
        overlay.appendChild(dialog);
        document.body.appendChild(overlay);

        function closeModal() {
            content.innerHTML = '';
            overlay.classList.remove('mme-open');
        }

        return {
            open(node) {
                content.innerHTML = '';
                content.appendChild(node);
                overlay.classList.add('mme-open');
            },
            close: closeModal
        };
    }

    function scanForLinks(root) {
        if (!(root instanceof Element || root instanceof Document || root instanceof DocumentFragment)) return;

        const anchors = [];
        if (root instanceof HTMLAnchorElement && root.href) {
            anchors.push(root);
        }

        const found = typeof root.querySelectorAll === 'function'
            ? root.querySelectorAll('a[href]')
            : [];

        anchors.push(...found);

        for (const link of anchors) {
            if (!(link instanceof HTMLAnchorElement)) continue;
            processLink(link);
        }
    }

    function findChatScope(link) {
        if (!(link instanceof Element)) return null;
        if (link.closest(EXCLUDED_SCOPE_SELECTORS)) return null;

        for (const selector of CHAT_SCOPE_SELECTORS) {
            const match = link.closest(selector);
            if (match) return match;
        }

        let current = link.parentElement;
        while (current && current !== document.body) {
            if (current.matches?.(EXCLUDED_SCOPE_SELECTORS)) return null;
            if (looksLikeChatNode(current)) return current;
            current = current.parentElement;
        }

        return null;
    }

    function isInfoScope(link) {
        if (!(link instanceof Element)) return false;
        if (link.closest(EXCLUDED_SCOPE_SELECTORS)) return false;
        return true;
    }

    function looksLikeChatNode(node) {
        if (!(node instanceof Element)) return false;

        const role = (node.getAttribute('role') || '').toLowerCase();
        if (role === 'log' || role === 'article' || role === 'listitem') return true;

        const classBlob = [
            typeof node.className === 'string' ? node.className : '',
            node.id || '',
            node.getAttribute('data-id') || '',
            node.getAttribute('data-cid') || ''
        ].join(' ').toLowerCase();

        return /(chat|charlog|chatlog|message|msg|speech|ooc|ic|logentry|log-entry|logline|line)/i.test(classBlob);
    }

    function processLink(link) {
        if (link.dataset.mmeProcessed === '1') return;

        const info = parseLinkInfo(link.href);
        if (!info) return;

        const inChatScope = !!findChatScope(link);
        const inInfoScope = info.directives.info && isInfoScope(link);

        if (!inChatScope && !inInfoScope) return;

        link.dataset.mmeProcessed = '1';

        const key = buildEmbedKey(info);

        if (info.directives.popup) {
            preparePopupLink(link, info);
            return;
        }

        if (info.directives.mediaUrl && info.type === 'image') {
            applyMediaDirective(link, info.directives);
            return;
        }

        if (info.directives.emot && info.type === 'image') {
            const img = createEmotImage(info.directives.mediaUrl || info.mediaSrc);
            replaceLinkWithNode(link, img, false);
            return;
        }

        if (info.directives.icon && info.type === 'image') {
            const img = createIconImage(info.directives.mediaUrl || info.mediaSrc);
            replaceLinkWithNode(link, img, false);
            return;
        }

        const embedNode = createEmbedNode(info);
        if (!embedNode) return;

        if (info.directives.replace) {
            const holder = wrapEmbed(embedNode, key);
            replaceLinkWithNode(link, holder, true);
            return;
        }

        appendEmbedToParentEnd(link, embedNode, key);
    }

    function parseLinkInfo(rawHref) {
        let url;
        try {
            url = new URL(rawHref, location.href);
        } catch {
            return null;
        }

        const directives = parseDirectives(url.hash, url);
        const baseWithoutHash = `${url.origin}${url.pathname}${url.search}`;
        const mediaSrc = baseWithoutHash + directives.nativeHash;
        const pathname = url.pathname.toLowerCase();

        const youtubeMatch = baseWithoutHash.match(/(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?|shorts)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/i);
        if (youtubeMatch) {
            return {
                type: 'youtube',
                originalUrl: mediaSrc,
                mediaSrc,
                directives,
                videoId: youtubeMatch[1],
                nativeHashParts: directives.nativeHashParts
            };
        }

        const spotifyMatch = baseWithoutHash.match(/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/i);
        if (spotifyMatch) {
            return {
                type: 'spotify',
                originalUrl: mediaSrc,
                mediaSrc,
                directives,
                spotifyKind: spotifyMatch[1],
                spotifyId: spotifyMatch[2]
            };
        }

        if (isSoundCloudUrl(url)) {
            return {
                type: 'soundcloud',
                originalUrl: mediaSrc,
                mediaSrc,
                directives
            };
        }

        const detectedType = detectDirectMediaType(url, pathname);
        if (detectedType) {
            return {
                type: detectedType,
                originalUrl: mediaSrc,
                mediaSrc,
                directives,
                isDiscordCdn: isDiscordCdn(url)
            };
        }

        return null;
    }

    function parseDirectives(hash, baseUrl) {
        const result = {
            play: false,
            replace: false,
            emot: false,
            popup: false,
            icon: false,
            info: false,
            width: null,
            height: null,
            mediaUrl: null,
            nativeHash: '',
            nativeHashParts: []
        };

        if (!hash || hash.length < 2) return result;

        const tokens = decodeURIComponent(hash.slice(1))
            .split(';')
            .map(token => token.trim())
            .filter(Boolean);

        for (const token of tokens) {
            if (token === 'play') {
                result.play = true;
                continue;
            }

            if (token === 'replace') {
                result.replace = true;
                continue;
            }

            if (token === 'emot') {
                result.emot = true;
                continue;
            }

            if (token === 'popup') {
                result.popup = true;
                continue;
            }

            if (token === 'icon') {
                result.icon = true;
                continue;
            }

            if (token === 'info') {
                result.info = true;
                continue;
            }

            if (/^h:\d+$/i.test(token)) {
                const value = parseInt(token.slice(2), 10);
                if (value > 0) result.height = value;
                continue;
            }

            if (/^w:\d+$/i.test(token)) {
                const value = parseInt(token.slice(2), 10);
                if (value > 0) result.width = value;
                continue;
            }

            if (/^media:/i.test(token)) {
                const value = token.slice(6).trim();
                if (value) result.mediaUrl = normalizeMediaUrl(value, baseUrl);
                continue;
            }

            result.nativeHashParts.push(token);
        }

        if (result.nativeHashParts.length > 0) {
            result.nativeHash = '#' + result.nativeHashParts.join(';');
        }

        return result;
    }

    function normalizeMediaUrl(value, baseUrl) {
        if (/^https?:\/\//i.test(value)) return value;
        if (value.startsWith('//')) return `${location.protocol}${value}`;

        try {
            if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) {
                return new URL(value, `${baseUrl.origin}${baseUrl.pathname}${baseUrl.search}`).href;
            }
        } catch {}

        return `https://${value}`;
    }

    function detectDirectMediaType(url, pathname) {
        if (/\.(jpeg|jpg|gif|png|webp|bmp|svg|avif)$/i.test(pathname)) return 'image';
        if (/\.(mp4|webm|ogv|mov|m4v)$/i.test(pathname)) return 'video';
        if (/\.(mp3|wav|ogg|flac|m4a|opus|aac)$/i.test(pathname)) return 'audio';

        if (isDiscordCdn(url)) {
            if (/\.(jpeg|jpg|gif|png|webp|bmp|svg|avif)$/i.test(pathname)) return 'image';
            if (/\.(mp4|webm|ogv|mov|m4v)$/i.test(pathname)) return 'video';
            if (/\.(mp3|wav|ogg|flac|m4a|opus|aac)$/i.test(pathname)) return 'audio';
        }

        return null;
    }

    function isDiscordCdn(url) {
        return /(^|\.)cdn\.discordapp\.com$|(^|\.)media\.discordapp\.net$|(^|\.)images-ext-\d+\.discordapp\.net$/i.test(url.hostname);
    }

    function isSoundCloudUrl(url) {
        return /(^|\.)soundcloud\.com$|(^|\.)snd\.sc$/i.test(url.hostname);
    }

    function buildEmbedKey(info) {
        return [
            info.type,
            info.mediaSrc,
            info.directives.play ? 'play' : '',
            info.directives.popup ? 'popup' : '',
            info.directives.replace ? 'replace' : '',
            info.directives.info ? 'info' : '',
            info.directives.width || '',
            info.directives.height || '',
            info.directives.mediaUrl || ''
        ].join('|');
    }

    function createEmbedNode(info) {
        const d = info.directives;

        if (info.type === 'youtube') {
            const iframe = document.createElement('iframe');
            const params = new URLSearchParams();
            params.set('rel', '0');
            params.set('playsinline', '1');
            if (d.play) params.set('autoplay', '1');

            const startSeconds = extractYouTubeStart(info.nativeHashParts || []);
            if (startSeconds > 0) params.set('start', String(startSeconds));

            iframe.src = `https://www.youtube-nocookie.com/embed/${info.videoId}?${params.toString()}`;
            iframe.width = '560';
            iframe.height = '315';
            iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
            iframe.allowFullscreen = true;
            return iframe;
        }

        if (info.type === 'spotify') {
            const iframe = document.createElement('iframe');
            const params = new URLSearchParams();
            if (d.play) params.set('autoplay', '1');

            const suffix = params.toString() ? `?${params.toString()}` : '';
            iframe.src = `https://open.spotify.com/embed/${info.spotifyKind}/${info.spotifyId}${suffix}`;
            iframe.width = '300';
            iframe.height = String(info.spotifyKind === 'track' ? 152 : 352);
            iframe.allow = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture';
            return iframe;
        }

        if (info.type === 'soundcloud') {
            const iframe = document.createElement('iframe');
            const scParams = new URLSearchParams({
                url: info.originalUrl,
                auto_play: d.play ? 'true' : 'false',
                visual: 'true'
            });

            iframe.src = `https://w.soundcloud.com/player/?${scParams.toString()}`;
            iframe.width = '100%';
            iframe.height = '300';
            iframe.allow = 'autoplay';
            return iframe;
        }

        if (info.type === 'image') {
            const img = document.createElement('img');
            img.src = d.mediaUrl || info.mediaSrc;
            img.loading = 'lazy';
            applyMediaDimensions(img, d);
            return img;
        }

        if (info.type === 'video') {
            const video = document.createElement('video');
            video.src = info.mediaSrc;
            video.controls = true;
            video.playsInline = true;

            if (d.play) {
                video.autoplay = true;
                video.muted = true;
            }

            applyMediaDimensions(video, d);
            return video;
        }

        if (info.type === 'audio') {
            const audio = document.createElement('audio');
            audio.src = info.mediaSrc;
            audio.controls = true;
            if (d.play) audio.autoplay = true;
            return audio;
        }

        return null;
    }

    function extractYouTubeStart(hashParts) {
        for (const part of hashParts) {
            const lowered = String(part).toLowerCase();

            if (/^t=\d+$/.test(lowered)) return parseInt(lowered.slice(2), 10);
            if (/^start=\d+$/.test(lowered)) return parseInt(lowered.slice(6), 10);

            const timeMatch = lowered.match(/^t=(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
            if (timeMatch) {
                const h = parseInt(timeMatch[1] || '0', 10);
                const m = parseInt(timeMatch[2] || '0', 10);
                const s = parseInt(timeMatch[3] || '0', 10);
                return h * 3600 + m * 60 + s;
            }
        }

        return 0;
    }

    function applyMediaDimensions(node, directives) {
        if (!(node instanceof HTMLElement)) return;

        if (directives.width) {
            node.style.width = `${directives.width}px`;
        } else {
            node.style.width = 'auto';
        }

        if (directives.height) {
            node.style.height = `${directives.height}px`;
        } else {
            node.style.height = 'auto';
        }

        if (directives.width && !directives.height) {
            node.style.height = 'auto';
        }

        if (directives.height && !directives.width) {
            node.style.width = 'auto';
        }
    }

    function wrapEmbed(embedNode, key) {
        const holder = document.createElement('span');
        holder.className = 'mme-embed-holder';
        holder.dataset.mmeEmbedKey = key;

        const box = document.createElement('span');
        box.className = 'mme-embed-box';

        box.appendChild(embedNode);
        holder.appendChild(box);
        return holder;
    }

    function appendEmbedToParentEnd(link, embedNode, key) {
        const parent = link.parentNode;
        if (!(parent instanceof Element)) return;

        const duplicate = Array.from(parent.children).find(child =>
            child instanceof HTMLElement &&
            child.classList.contains('mme-embed-holder') &&
            child.dataset.mmeEmbedKey === key
        );

        if (duplicate) return;

        const holder = wrapEmbed(embedNode, key);
        parent.appendChild(holder);
    }

    function replaceLinkWithNode(link, node, withBreaks) {
        const parent = link.parentNode;
        if (!parent) return;

        if (!withBreaks) {
            link.replaceWith(node);
            return;
        }

        const before = document.createElement('br');
        const after = document.createElement('br');
        link.replaceWith(before, node, after);
    }

    function createEmotImage(src) {
        const img = document.createElement('img');
        img.src = src;
        img.loading = 'lazy';
        img.className = 'mme-inline-emot';
        return img;
    }

    function createIconImage(src) {
        const img = document.createElement('img');
        img.src = src;
        img.loading = 'lazy';
        img.className = 'badge--icon';
        return img;
    }

    function applyMediaDirective(link, directives) {
        const img = document.createElement('img');
        img.src = directives.mediaUrl;
        img.loading = 'lazy';

        if (directives.height) {
            img.style.height = `${directives.height}px`;
            img.style.width = directives.width ? `${directives.width}px` : 'auto';
        }

        if (directives.width) {
            img.style.width = `${directives.width}px`;
            if (!directives.height) img.style.height = 'auto';
        }

        if (!directives.width && !directives.height) {
            img.style.height = 'auto';
            img.style.maxWidth = '100%';
        }

        link.textContent = '';
        link.appendChild(img);
    }

    function preparePopupLink(link, info) {
        if (info.directives.mediaUrl && info.type === 'image') {
            applyMediaDirective(link, info.directives);
        } else if (info.directives.emot && info.type === 'image') {
            const img = createEmotImage(info.directives.mediaUrl || info.mediaSrc);
            link.textContent = '';
            link.appendChild(img);
        } else if (info.directives.icon && info.type === 'image') {
            const img = createIconImage(info.directives.mediaUrl || info.mediaSrc);
            link.textContent = '';
            link.appendChild(img);
        }

        link.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();

            const node = createEmbedNode(info);
            if (!node) return;

            if (node instanceof HTMLIFrameElement && info.type === 'youtube') {
                node.width = '900';
                node.height = '506';
            }

            if (node instanceof HTMLImageElement || node instanceof HTMLVideoElement) {
                node.style.maxWidth = '88vw';
                node.style.maxHeight = '80vh';
            }

            modalApi.open(node);
        });
    }
})();