Mucklet Media Embedder Ultra

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

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

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

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

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

이 스크립트를 설치하려면 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);
        });
    }
})();