Mucklet Media Embedder Ultra

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
        });
    }
})();