YouTube Full Dates v1

Show full upload dates instead of "1 year ago", "2 weeks ago", etc. Customize date and time format to your preference.

当前为 2025-11-09 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Full Dates v1
// @namespace    YouTube Full Dates v1
// @version      1
// @description  Show full upload dates instead of "1 year ago", "2 weeks ago", etc. Customize date and time format to your preference.
// @author       InMirrors
// @match        https://www.youtube.com/*
// @icon         https://www.youtube.com/s/desktop/814d40a6/img/favicon_144x144.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ===== Constants =====
    const PROCESSED_MARKER = '\u200B'; // Zero-Width Space
    const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';

    // Keywords for identifying relative date strings
    const DATE_TIME_KEYWORDS_EN = 'second minute hour day week month year';
    const DATE_TIME_KEYWORDS_ZH = '秒 分 时 時 天 日 周 週 月 年';
    const DEFAULT_DATE_TIME_KEYWORDS = [DATE_TIME_KEYWORDS_EN, DATE_TIME_KEYWORDS_ZH].join(' ').split(' ');
    const DEFAULT_AGO_KEYWORDS = ['ago', '前'];
    const DEFAULT_OLD_UPLOAD_KEYWORDS = ['day', 'week', 'month', 'year', '天', '日', '周', '週', '月', '年'];
    const DEFAULT_MONTH_NAMES = 'JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC';
    const DEFAULT_DAY_NAMES = 'Sun Mon Tue Wed Thu Fri Sat';

    // ===== Settings =====
    const SETTINGS = GM_getValue("basic", {});
    const DATE_FORMAT = SETTINGS.dateFormat || DEFAULT_DATE_FORMAT;
    const DATE_TIME_KEYWORDS = SETTINGS.dateTimeKeywords?.length ? SETTINGS.dateTimeKeywords : DEFAULT_DATE_TIME_KEYWORDS;
    const AGO_KEYWORDS = SETTINGS.agoKeywords?.length ? SETTINGS.agoKeywords : DEFAULT_AGO_KEYWORDS;
    const OLD_UPLOAD_KEYWORDS = SETTINGS.oldUploadKeywords?.length ? SETTINGS.oldUploadKeywords : DEFAULT_OLD_UPLOAD_KEYWORDS;
    const MONTH_NAMES = SETTINGS.monthNames?.length ? SETTINGS.monthNames : DEFAULT_MONTH_NAMES.split(' ');
    const DAY_NAMES = SETTINGS.dayNames?.length ? SETTINGS.dayNames : DEFAULT_DAY_NAMES.split(' ');
    const PREPEND_DATES_ENABLED = SETTINGS.prependDatesEnabled ?? false;

    // Advanced settings
    let timerModeEnabled = GM_getValue("timerModeEnabled", false);
    let formattingTimer = null;
    let useAllConfigsEnabled = GM_getValue("useAllConfigsEnabled", false);
    let findValidConfigEnable = GM_getValue("findValidConfigEnable", false);

    // Debug mode
    let debugModeEnabled = GM_getValue("debugModeEnabled", false);
    let debugVidsArrayLength = GM_getValue("debugVidsArrayLength", 4);

    // ===== Debug Mode Menu Commands =====
    if (debugModeEnabled) {
        GM_registerMenuCommand(`Toggle Debug mode ${debugModeEnabled ? "OFF" : "ON"}`, () => {
            debugModeEnabled = !debugModeEnabled;
            GM_setValue("debugModeEnabled", debugModeEnabled);
            alert(`Debug mode is now ${debugModeEnabled ? "ON" : "OFF"}`);
        });

        GM_registerMenuCommand("Set video array length", () => {
            const input = prompt("Please enter a number, 0 to disable slicing:", debugVidsArrayLength);
            if (input == null) return;

            if (input.trim() !== "" && !isNaN(input)) {
                debugVidsArrayLength = Number(input);
                GM_setValue("debugVidsArrayLength", debugVidsArrayLength);
                alert("Value updated to: " + debugVidsArrayLength);
            } else {
                alert("Invalid input. Please enter a number.");
            }
        });

        GM_registerMenuCommand(`Toggle all configs mode ${useAllConfigsEnabled ? "OFF" : "ON"}`, () => {
            useAllConfigsEnabled = !useAllConfigsEnabled;
            GM_setValue("useAllConfigsEnabled", useAllConfigsEnabled);
            alert(`All configs mode is now ${useAllConfigsEnabled ? "ON" : "OFF"}`);
        });

        GM_registerMenuCommand(`Toggle find valid config mode ${findValidConfigEnable ? "OFF" : "ON"}`, () => {
            findValidConfigEnable = !findValidConfigEnable;
            if (findValidConfigEnable) {
                useAllConfigsEnabled = true;
                GM_setValue("useAllConfigsEnabled", useAllConfigsEnabled);
            }
            GM_setValue("findValidConfigEnable", findValidConfigEnable);
            alert(`Find valid config mode is now ${findValidConfigEnable ? "ON" : "OFF"}`);
        });
    }

    // ===== Utility Functions =====

    /**
     * Validate configuration array
     * @param {Array} configs - Array of config objects
     * @returns {boolean} - True if all configs are valid
     */
    function validateConfigs(configs) {
        const errors = [];

        configs.forEach(config => {
            const { id = '[no id]' } = config;
            const stringProps = ["id", "videoContainerSelector", "metaSpansSelector", "vidLinkSelector"];

            stringProps.forEach(prop => {
                if (typeof config[prop] !== "string" || config[prop].trim() === "") {
                    errors.push(`${id}: "${prop}" must be a non-empty string`);
                }
            });

            if (!(config.urlPattern instanceof RegExp)) {
                errors.push(`${id}: "urlPattern" must be a RegExp`);
            }

            if (typeof config.shouldCreateDateSpan !== "boolean") {
                errors.push(`${id}: "shouldCreateDateSpan" must be a boolean`);
            }

            if (config.shouldCreateDateSpan === true && typeof config.insertAfterIndex !== "number") {
                errors.push(`${id}: "insertAfterIndex" must be a number when shouldCreateDateSpan is true`);
            }
        });

        if (errors.length > 0) {
            console.log('[YouTube Date Formatter] Validation errors:');
            errors.forEach(err => console.log(" - " + err));
            return false;
        }

        console.log('[YouTube Date Formatter] All configs are valid!');
        return true;
    }

    /**
     * Find elements containing specified keywords
     * @param {NodeList | Array} nodeList - Elements to search
     * @param {string[]} keywords - Keywords to search for
     * @param {boolean} findAll - Return all matches or just first
     * @returns {Element | Element[] | undefined}
     */
    function findElementsByKeywords(nodeList, keywords, findAll = false) {
        const elements = Array.from(nodeList);

        if (findAll) {
            return elements.filter(el =>
                keywords.some(keyword => el.textContent.includes(keyword))
            );
        }

        return elements.find(el =>
            keywords.some(keyword => el.textContent.includes(keyword))
        );
    }

    /**
     * Format a date into a string with custom template
     * Supported tokens:
     * - yyyy: 4-digit year
     * - yy: 2-digit year
     * - MMM: uppercase month name
     * - MM: 2-digit month
     * - dd: 2-digit day
     * - ww: day of week
     * - HH: 24-hour format (00-23)
     * - hh: 12-hour format (01-12)
     * - ap: AM/PM
     * - mm: 2-digit minutes
     * - ss: 2-digit seconds
     *
     * @param {string|number|Date} date - Date to format
     * @param {string} template - Format template (default: "yyyy-MM-dd HH:mm:ss")
     * @param {boolean} useLocal - Use local time vs UTC (default: true)
     * @param {string[]} months - Month names (default: English JAN-DEC)
     * @param {string[]} days - Day names (default: English Sun-Sat)
     * @returns {string} Formatted date string
     */
    function getDateStr(date, template = "yyyy-MM-dd HH:mm:ss", useLocal = true, months, days) {
        const defaultMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
        const defaultDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
        const monthNames = months || defaultMonths;
        const dayNames = days || defaultDays;

        const dt = new Date(date);
        if (isNaN(dt.getTime())) return "";

        const getMethod = (key) => useLocal ? dt[`get${key}`]() : dt[`getUTC${key}`]();
        const pad = (num, size = 2) => String(num).padStart(size, "0");

        const map = {
            yyyy: String(getMethod("FullYear")),
            yy: String(getMethod("FullYear")).slice(-2),
            MMM: monthNames[getMethod("Month")],
            MM: pad(getMethod("Month") + 1),
            dd: pad(getMethod("Date")),
            ww: dayNames[getMethod("Day")],
            HH: pad(getMethod("Hours")),
            hh: pad((getMethod("Hours") % 12) || 12),
            ap: getMethod("Hours") < 12 ? "AM" : "PM",
            mm: pad(getMethod("Minutes")),
            ss: pad(getMethod("Seconds")),
        };

        return template.replaceAll(/(yy(yy)?|MMM?|dd|ww|HH|hh|mm|ss|ap)/g, n => map[n]);
    }

    /**
     * Extract upload date from page metadata
     * @returns {string | null} Upload date or null
     */
    function getUploadDate() {
        const el = document.body.querySelector('player-microformat-renderer script');
        if (!el) return null;

        let parts = el.textContent.split('"startDate":"', 2);
        if (parts.length === 2) {
            return parts[1].split('"', 1)[0];
        }

        parts = el.textContent.split('"uploadDate":"', 2);
        if (parts.length === 2) {
            return parts[1].split('"', 1)[0];
        }

        return null;
    }

    /**
     * Check if video is currently live broadcasting
     * @returns {boolean | null}
     */
    function getIsLiveBroadcast() {
        const el = document.body.querySelector('player-microformat-renderer script');
        if (!el) return null;

        let parts = el.textContent.split('"isLiveBroadcast":', 2);
        if (parts.length !== 2) return false;

        const isLiveBroadcast = !!parts[1].split(',', 1)[0];
        if (!isLiveBroadcast) return false;

        parts = el.textContent.split('"endDate":"', 2);
        if (parts.length === 2) return false;

        return true;
    }

    /**
     * Update upload date in video description
     */
    function processDescription() {
        let uploadDate = getUploadDate();
        if (!uploadDate) return;

        uploadDate = getDateStr(uploadDate, DATE_FORMAT, true, MONTH_NAMES, DAY_NAMES);
        const isLiveBroadcast = getIsLiveBroadcast();

        document.body.classList.toggle('ytud-description-live', isLiveBroadcast);

        let el = document.querySelector('#info-container > #info > b');
        if (!el) {
            const span = document.querySelector('#info-container > #info > span:nth-child(1)');
            if (span) {
                el = document.createElement('b');
                el.textContent = uploadDate;
                span.insertAdjacentElement('afterend', el);
            }
        } else {
            if (el.parentNode.children[1] !== el) {
                const container = el.parentNode;
                el = container.removeChild(el);
                container.children[0].insertAdjacentElement('afterend', el);
            }
            if (el.firstChild.nodeValue !== uploadDate) {
                el.firstChild.nodeValue = uploadDate;
            }
        }
    }

    /**
     * Process topic sidebar videos
     */
    function processTopicSidebar() {
        const vids = document.querySelectorAll('#contents > ytd-universal-watch-card-renderer > #sections > ytd-watch-card-section-sequence-renderer > #lists > ytd-vertical-watch-card-list-renderer > #items > ytd-watch-card-compact-video-renderer');

        vids.forEach((el) => {
            const holders = el.querySelectorAll('div.text-wrapper > yt-formatted-string.subtitle');
            if (holders.length === 0) return;

            const holder = holders[0];
            const separator = ' • ';
            const parts = holder.firstChild.nodeValue.split(separator, 2);
            if (parts.length < 2) return;

            const dateText = parts[1];
            const text = el.getAttribute('date-text');

            if (text !== null && text === dateText) return;

            el.setAttribute('date-text', dateText);
            const link = el.querySelector('a#thumbnail').getAttribute('href');
            const videoId = urlToVideoId(link);
            fetchAndUpdateUploadDate(videoId, holder, dateText);
        });
    }

    /**
     * Extract video ID from URL
     * @param {string} url - YouTube URL
     * @returns {string} Video ID
     */
    function urlToVideoId(url) {
        let parts = url.split('/shorts/', 2);
        url = parts.length === 2 ? parts[1] : parts[0];

        parts = url.split('v=', 2);
        url = parts.length === 2 ? parts[1] : parts[0];

        return url.split('&', 1)[0];
    }

    /**
     * Fetch upload date from YouTube API
     * @param {string} videoId - Video ID
     * @param {Function} callback - Callback with upload date
     */
    function getRemoteUploadDate(videoId, callback) {
        const body = {
            "context": { "client": { "clientName": "WEB", "clientVersion": "2.20240416.01.00" } },
            "videoId": videoId
        };

        fetch('https://www.youtube.com/youtubei/v1/player?prettyPrint=false', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body)
        })
        .then(response => {
            if (!response.ok) throw new Error('Network response was not ok');
            return response.json();
        })
        .then(data => {
            const object = data.microformat.playerMicroformatRenderer;

            if (object.liveBroadcastDetails?.isLiveNow) {
                callback(object.liveBroadcastDetails.startTimestamp);
            } else if (object.publishDate) {
                callback(object.publishDate);
            } else {
                callback(object.uploadDate);
            }
        })
        .catch(error => {
            console.error('[YouTube Date Formatter] Fetch error:', error);
        });
    }

    /**
     * Fetch and update upload date for a video
     * @param {string} videoId - Video ID
     * @param {HTMLElement} dateElem - Element containing date
     * @param {string} originalDateText - Original relative date text
     */
    function fetchAndUpdateUploadDate(videoId, dateElem, originalDateText) {
        getRemoteUploadDate(videoId, (uploadDate) => {
            const formattedDate = getDateStr(uploadDate, DATE_FORMAT, true, MONTH_NAMES, DAY_NAMES) + PROCESSED_MARKER;
            let displayText;

            // Show only formatted date for old uploads
            if (OLD_UPLOAD_KEYWORDS.some(keyword => originalDateText.includes(keyword))) {
                displayText = formattedDate;
            } else {
                // Show both dates for recent uploads
                displayText = PREPEND_DATES_ENABLED
                    ? `${formattedDate} · ${originalDateText}`
                    : `${originalDateText} · ${formattedDate}`;
            }

            dateElem.firstChild.nodeValue = displayText;
        });
    }

    /**
     * Find and process videos based on configuration
     * @param {object} config - Configuration object
     */
    function findAndProcessVids(config) {
        // Skip if URL doesn't match pattern
        if (config.urlPattern && !config.urlPattern.test(window.location.href) && !useAllConfigsEnabled) {
            return;
        }

        let vids = document.querySelectorAll(config.videoContainerSelector);
        if (vids.length === 0) return;

        // Limit processing in debug mode
        if (debugModeEnabled && debugVidsArrayLength !== 0 && vids.length > 1) {
            vids = Array.from(vids).slice(0, debugVidsArrayLength);
        }

        vids.forEach((vidContainer) => {
            const metaSpans = vidContainer.querySelectorAll(config.metaSpansSelector);
            if (metaSpans.length === 0) {
                if (debugModeEnabled && !findValidConfigEnable) {
                    console.warn(`[YouTube Date Formatter] No metadata span found for [${config.id}]`);
                }
                return;
            }

            let dateSpan;
            if (config.shouldCreateDateSpan) {
                // Create new date span element
                dateSpan = document.createElement('span');
                dateSpan.className = 'inline-metadata-item style-scope ytd-video-meta-block ytdf-date';
                dateSpan.appendChild(document.createTextNode(''));
                metaSpans[config.insertAfterIndex].insertAdjacentElement('afterend', dateSpan);
            } else {
                // Find existing date span
                const spansEndWithAgo = Array.from(metaSpans).filter(span =>
                    AGO_KEYWORDS.some(ago => span.textContent.includes(ago))
                );
                dateSpan = findElementsByKeywords(spansEndWithAgo, DATE_TIME_KEYWORDS);
            }

            if (!dateSpan) {
                if (debugModeEnabled && !findValidConfigEnable) {
                    console.warn(`[YouTube Date Formatter] Date span is null for [${config.id}]`);
                }
                return;
            }

            const dateText = dateSpan.textContent;
            if (!dateText || dateText.includes(PROCESSED_MARKER)) return;

            // Mark as processed
            dateSpan.firstChild.nodeValue = dateText + PROCESSED_MARKER;

            const vidLinkElem = vidContainer.querySelector(config.vidLinkSelector);
            if (!vidLinkElem) {
                if (debugModeEnabled && !findValidConfigEnable) {
                    console.warn(`[YouTube Date Formatter] No video link element found for [${config.id}]`);
                }
                return;
            }

            const vidLink = vidLinkElem.getAttribute('href');
            if (!vidLink) return;

            const videoId = urlToVideoId(vidLink);
            if (!videoId) return;

            if (findValidConfigEnable) {
                console.warn(`[YouTube Date Formatter] [${config.id}] is valid`);
            }

            fetchAndUpdateUploadDate(videoId, dateSpan, dateText);
        });
    }

    // ===== Configuration Array =====
    const configs = [
        {
            id: 'Video Page Sidebar',
            urlPattern: /watch\?v=/,
            videoContainerSelector: 'yt-lockup-view-model.lockup',
            metaSpansSelector: '.yt-core-attributed-string--link-inherit-color',
            vidLinkSelector: '.yt-lockup-view-model__content-image',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Homepage Videos',
            urlPattern: /www\.youtube\.com\/?$/,
            videoContainerSelector: 'ytd-rich-item-renderer.style-scope.ytd-rich-grid-renderer',
            metaSpansSelector: '.yt-core-attributed-string--link-inherit-color',
            vidLinkSelector: '.yt-lockup-view-model__content-image',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Homepage Shorts',
            urlPattern: /XXXwww\.youtube\.com\/?$/, // Disabled by default
            videoContainerSelector: 'dummy',
            metaSpansSelector: '#metadata-line > span',
            vidLinkSelector: '.yt-lockup-view-model__content-image',
            shouldCreateDateSpan: true,
            insertAfterIndex: 0,
        },
        {
            id: 'Search List Videos',
            urlPattern: /results\?search_query=/,
            videoContainerSelector: 'ytd-video-renderer.ytd-item-section-renderer',
            metaSpansSelector: '.inline-metadata-item',
            vidLinkSelector: '#thumbnail',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Search List Shorts',
            urlPattern: /XXXresults\?search_query=/, // Disabled by default
            videoContainerSelector: 'dummy',
            metaSpansSelector: '#metadata-line > span',
            vidLinkSelector: '.yt-lockup-view-model__content-image',
            shouldCreateDateSpan: true,
            insertAfterIndex: 0,
        },
        {
            id: 'Subscriptions',
            urlPattern: /subscriptions/,
            videoContainerSelector: '#dismissible',
            metaSpansSelector: '#metadata-line > span',
            vidLinkSelector: 'h3 > a',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Channel Videos',
            urlPattern: /www.youtube.com\/[^\/]+?\/videos/,
            videoContainerSelector: 'ytd-rich-grid-media.ytd-rich-item-renderer',
            metaSpansSelector: '#metadata-line > span',
            vidLinkSelector: 'h3 > a',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Channel Featured Videos',
            urlPattern: /www.youtube.com\/@[^\/]+?\/?(featured)?$/,
            videoContainerSelector: 'ytd-grid-video-renderer.yt-horizontal-list-renderer',
            metaSpansSelector: '#metadata-line > span',
            vidLinkSelector: 'a#thumbnail',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Channel For You Videos',
            urlPattern: /www.youtube.com\/@[^\/]+?\/?(featured)?$/,
            videoContainerSelector: 'ytd-channel-video-player-renderer.ytd-item-section-renderer',
            metaSpansSelector: '#metadata-line > span',
            vidLinkSelector: '#title a',
            shouldCreateDateSpan: false,
        },
        {
            id: 'Video Playlist',
            urlPattern: /playlist\?list=/,
            videoContainerSelector: 'ytd-playlist-video-renderer.ytd-playlist-video-list-renderer',
            metaSpansSelector: 'span.yt-formatted-string',
            vidLinkSelector: 'a#thumbnail',
            shouldCreateDateSpan: false,
        }
    ];

    // ===== Main Execution =====

    /**
     * Run all formatting functions
     */
    function runAllFormatters() {
        try {
            if (debugModeEnabled && !validateConfigs(configs)) return;

            processDescription();
            processTopicSidebar();
            configs.forEach(findAndProcessVids);
        } catch (error) {
            console.error('[YouTube Date Formatter] Error running formatters:', error);
        }
    }

    let validConfigs = [];

    /**
     * Update valid configuration list based on current URL
     */
    function updateSelectors() {
        const currentUrl = window.location.href;
        const newConfigs = configs.filter(config => config.urlPattern.test(currentUrl));

        if (newConfigs !== validConfigs) {
            validConfigs = newConfigs;
            if (debugModeEnabled) {
                console.log('[YouTube Date Formatter] Valid configs:', validConfigs);
            }
        }
    }

    /**
     * Update timer for formatting (used in timer mode)
     */
    function updateFormattingTimer() {
        if (formattingTimer !== null) clearInterval(formattingTimer);
        formattingTimer = setInterval(runAllFormatters, 1000);
    }

    let subscriptionsPageTimer = null;
    const subscriptionsPagePattern = /subscriptions/;

    /**
     * Handle special timer for subscriptions page
     */
    function handleSubscriptionsPageTimer() {
        if (subscriptionsPagePattern.test(window.location.href)) {
            subscriptionsPageTimer = setInterval(runAllFormatters, 1000);
        } else if (subscriptionsPageTimer !== null) {
            clearInterval(subscriptionsPageTimer);
            subscriptionsPageTimer = null;
        }
    }

    // Listen for YouTube navigation
    window.addEventListener('yt-navigate-finish', () => {
        updateSelectors();
        handleSubscriptionsPageTimer();
    });

    // Initialize
    updateSelectors();
    runAllFormatters();

    // MutationObserver for dynamic content changes
    const observer = new MutationObserver((mutationsList) => {
        const observerConfigs = useAllConfigsEnabled ? configs : validConfigs;
        if (observerConfigs.length === 0) {
            if (debugModeEnabled) {
                console.warn('[YouTube Date Formatter] No valid configs found, skipping observer...');
            }
            return;
        }

        // Subscriptions page handled by timer
        if (subscriptionsPagePattern.test(window.location.href)) return;

        let shouldRunFormatters = false;
        const videoContainerSelectors = observerConfigs
            .map(config => config.videoContainerSelector)
            .filter(selector => selector && typeof selector === 'string')
            .join(', ');

        for (const mutation of mutationsList) {
            // New elements added to DOM
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1) {
                        if (node.matches(videoContainerSelectors) || node.querySelector(videoContainerSelectors)) {
                            shouldRunFormatters = true;
                        }
                    }
                });
            }

            // Attribute changes (for channel video sorting)
            if (mutation.type === 'attributes') {
                const videoLinkSelector = configs.find(config => config.id === 'Channel Videos')?.vidLinkSelector;
                if (videoLinkSelector && mutation.target.matches(videoLinkSelector)) {
                    if (mutation.oldValue !== mutation.target.href) {
                        shouldRunFormatters = true;
                    }
                }
            }
        }

        if (shouldRunFormatters) {
            clearTimeout(window.formatterDebounce);
            window.formatterDebounce = setTimeout(runAllFormatters, 500);
        }
    });

    if (!timerModeEnabled) {
        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['href']
        });
    } else {
        updateFormattingTimer();
    }

    // Inject CSS
    const styleTag = document.createElement('style');
    styleTag.textContent = `
        #info > span:nth-child(3) { display: none !important; }
        #info > span:nth-child(4) { display: none !important; }
        #info > b { font-weight: 500 !important; margin-left: 6px !important; }
        #date-text { display: none !important; }
        .ytud-description-live #info > span:nth-child(1) { display: none !important; }
        .ytud-description-live #info > b { margin-left: 0 !important; margin-right: 6px !important; }
    `;
    document.head.appendChild(styleTag);

    // ===== Settings UI =====

    const translations = {
        en: {
            menuTitle: 'Open Settings Panel',
            panelTitle: 'Settings',
            saveBtn: 'Save',
            resetBtn: 'Reset',
            languageLabel: 'Language',
            prependDatesLabel: 'Prepend dates',
            prependDatesHelp: 'Insert absolute dates before the original relative dates. Default is after.',
            dateFormatLabel: 'Date format',
            dateFormatHelp: 'The format for the displayed date. Check the script documentation for syntax help.',
            oldUploadKeywordsLabel: 'Keywords for old uploads',
            oldUploadKeywordsHelp: 'Keywords to identify old uploads. Only formatted dates will be shown for these.',
            dateTimeKeywordsLabel: 'Date and time keywords',
            dateTimeKeywordsHelp: 'Keywords used to identify relative date strings.',
            agoKeywordsLabel: 'Ago keyword',
            agoKeywordsHelp: 'Keyword used to identify the "ago" part of relative dates, such as "ago" in "1 day ago".',
            monthNamesLabel: 'Month names',
            monthNamesHelp: 'The names of the months used in the date format (MMM).',
            dayNamesLabel: 'Day of the week names',
            dayNamesHelp: 'The names of the days of the week used in the date format (ww).',
            stringsFooterBasic: 'Click the question mark icon to use the example.',
            stringsFooterI18n: 'You must use translations in your YouTube language to fill in these keyword settings if your YouTube language is not English.',
            helpTooltip: '{desc} Example:\n{example}',
            inputPlaceholder: 'Enter value...',
            alertSaved: 'Settings saved! Please refresh the page to apply changes.',
            confirmReset: 'Reset all settings to defaults?'
        }
    };

    let currentStrings = translations.en;

    const panelCSS = `
        :root { --ytdf-panel-bg: #fff; --ytdf-accent: #1e88e5; }
        #ytdf-panel {
            width: 520px;
            margin: 40px auto;
            border-radius: 10px;
            max-height: 600px;
            overflow: auto;
            background: var(--ytdf-panel-bg);
            box-shadow: 0 6px 24px rgba(0,0,0,0.12);
            font-family: Arial, sans-serif;
            color: black;
            position: fixed;
            top: 50px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 9999;
            display: none;
        }
        .ytdf-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #eef2f7; }
        .ytdf-header h1 { font-size: 18px; font-weight: bolder; margin: 0; }
        .ytdf-header .ytdf-buttons { display: flex; gap: 8px; }
        .ytdf-button { background: var(--ytdf-accent); color: #fff; border: none; padding: 8px 12px; border-radius: 7px; cursor: pointer; }
        .ytdf-button.ytdf-secondary { background: #edf2f7; color: #111; border: 1px solid #d1dae8; }
        .ytdf-container { padding: 18px; display: grid; gap: 14px; }
        .ytdf-section { padding: 12px; border-radius: 8px; box-shadow: 0px 0px 6px lightgray; }
        .ytdf-section-title { font-size: 18px; font-weight: bolder; margin-block: 10px; }
        .ytdf-section.ytdf-selects { background: hsl(110,60%,99%); border-left: 4px solid hsl(110,50%,70%); }
        .ytdf-section.ytdf-switches { background: hsl(190,60%,99%); border-left: 4px solid hsl(190,50%,70%); }
        .ytdf-section.ytdf-strings-basic { background: hsl(250,60%,99%); border-left: 4px solid hsl(250,70%,70%); }
        .ytdf-section.ytdf-strings-i18n { background: hsl(340,60%,99%); border-left: 4px solid hsl(340,70%,70%); }
        .ytdf-switch-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; }
        .ytdf-switch-row label { flex: 1; display: flex; align-items: center; gap: 8px; font-size: 14px; }
        .ytdf-toggle { width: 40px; height: 22px; background: #cbd5e1; border-radius: 20px; position: relative; cursor: pointer; flex: 0 0 auto; }
        .ytdf-toggle .ytdf-knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; background: white; border-radius: 50%; transition: all 0.18s; }
        .ytdf-toggle.ytdf-on { background: #4fbe79; }
        .ytdf-toggle.ytdf-on .ytdf-knob { left: 20px; }
        .ytdf-string-label-help, .ytdf-select-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px dashed rgba(0,0,0,0.04); }
        .ytdf-string-row:first-child, .ytdf-select-row:first-child { border-top: none; }
        .ytdf-select-label { width: 140px; font-size: 14px; }
        .ytdf-string-label { font-size: 14px; }
        .ytdf-string-input, .ytdf-select-input { flex: 1; }
        .ytdf-string-input input[type="text"], .ytdf-select-input select { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #cbd5e1; }
        .ytdf-help { margin-left: auto; cursor: pointer; user-select: none; font-size: 12px; color: #0366d6; }
        .ytdf-footer-note { font-size: 12px; color: #718096; margin-top: 6px; }
        @media (max-width: 600px) { #ytdf-panel { width: 92%; top: 20px; left: 4%; transform: none; } }
    `;

    GM_addStyle(panelCSS);

    /**
     * Create settings panel element using DOM manipulation
     * @returns {HTMLElement} Panel element
     */
    function createPanelElement() {
        const panel = document.createElement('div');
        panel.id = 'ytdf-panel';

        // Header
        const header = document.createElement('div');
        header.className = 'ytdf-header';

        const h1 = document.createElement('h1');
        h1.dataset.langKey = 'panelTitle';
        h1.textContent = currentStrings.panelTitle;

        const buttonsDiv = document.createElement('div');
        buttonsDiv.className = 'ytdf-buttons';

        const resetButton = document.createElement('button');
        resetButton.className = 'ytdf-button ytdf-secondary';
        resetButton.id = 'ytdf-reset-btn';
        resetButton.dataset.langKey = 'resetBtn';
        resetButton.textContent = currentStrings.resetBtn;

        const saveButton = document.createElement('button');
        saveButton.className = 'ytdf-button';
        saveButton.id = 'ytdf-save-btn';
        saveButton.dataset.langKey = 'saveBtn';
        saveButton.textContent = currentStrings.saveBtn;

        buttonsDiv.appendChild(resetButton);
        buttonsDiv.appendChild(saveButton);
        header.appendChild(h1);
        header.appendChild(buttonsDiv);
        panel.appendChild(header);

        // Container
        const container = document.createElement('div');
        container.className = 'ytdf-container';

        // Language Section (removed - English only)
        // Switches Section
        const switchesSection = document.createElement('div');
        switchesSection.className = 'ytdf-section ytdf-switches';

        const switchRow = document.createElement('div');
        switchRow.className = 'ytdf-switch-row';

        const label = document.createElement('label');

        const toggleDiv = document.createElement('div');
        toggleDiv.className = 'ytdf-toggle';
        toggleDiv.dataset.key = 'prependDatesEnabled';
        toggleDiv.setAttribute('role', 'switch');
        toggleDiv.setAttribute('tabindex', '0');

        const knobDiv = document.createElement('div');
        knobDiv.className = 'ytdf-knob';
        toggleDiv.appendChild(knobDiv);

        const spanPrependDates = document.createElement('span');
        spanPrependDates.dataset.langKey = 'prependDatesLabel';
        spanPrependDates.textContent = currentStrings.prependDatesLabel;

        const helpDiv = document.createElement('div');
        helpDiv.className = 'ytdf-help';
        helpDiv.dataset.langKey = 'prependDatesHelp';
        helpDiv.dataset.desc = currentStrings.prependDatesHelp;
        helpDiv.textContent = '❓';

        label.appendChild(toggleDiv);
        label.appendChild(spanPrependDates);
        label.appendChild(helpDiv);
        switchRow.appendChild(label);
        switchesSection.appendChild(switchRow);

        // Strings Basic Section
        const stringsBasicSection = document.createElement('div');
        stringsBasicSection.className = 'ytdf-section ytdf-strings-basic';

        const footerNoteBasic = document.createElement('div');
        footerNoteBasic.className = 'ytdf-footer-note';
        footerNoteBasic.dataset.langKey = 'stringsFooterBasic';
        footerNoteBasic.textContent = currentStrings.stringsFooterBasic;
        stringsBasicSection.appendChild(footerNoteBasic);

        // Create string input rows
        const basicInputs = [
            { key: 'dateFormat', example: 'yyyy-MM-dd' },
            { key: 'oldUploadKeywords', example: 'day week month year' }
        ];

        basicInputs.forEach(({ key, example }) => {
            const row = createStringInputRow(key, example);
            stringsBasicSection.appendChild(row);
        });

        // Strings I18n Section
        const stringsI18nSection = document.createElement('div');
        stringsI18nSection.className = 'ytdf-section ytdf-strings-i18n';

        const footerNoteI18n = document.createElement('div');
        footerNoteI18n.className = 'ytdf-footer-note';
        footerNoteI18n.dataset.langKey = 'stringsFooterI18n';
        footerNoteI18n.textContent = currentStrings.stringsFooterI18n;
        stringsI18nSection.appendChild(footerNoteI18n);

        const i18nInputs = [
            { key: 'dateTimeKeywords', example: 'second minute hour day week month year' },
            { key: 'agoKeywords', example: 'ago' },
            { key: 'monthNames', example: 'JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC' },
            { key: 'dayNames', example: 'Sun Mon Tue Wed Thu Fri Sat' }
        ];

        i18nInputs.forEach(({ key, example }) => {
            const row = createStringInputRow(key, example);
            stringsI18nSection.appendChild(row);
        });

        container.appendChild(switchesSection);
        container.appendChild(stringsBasicSection);
        container.appendChild(stringsI18nSection);
        panel.appendChild(container);

        return panel;
    }

    /**
     * Create a string input row for settings
     * @param {string} key - Setting key
     * @param {string} example - Example value
     * @returns {HTMLElement} Row element
     */
    function createStringInputRow(key, example) {
        const row = document.createElement('div');
        row.className = 'ytdf-string-row';
        row.dataset.key = key;

        const labelHelp = document.createElement('div');
        labelHelp.className = 'ytdf-string-label-help';

        const label = document.createElement('div');
        label.className = 'ytdf-string-label';
        label.dataset.langKey = `${key}Label`;
        label.textContent = currentStrings[`${key}Label`] || key;

        const help = document.createElement('div');
        help.className = 'ytdf-help';
        help.dataset.langKey = `${key}Help`;
        help.dataset.example = example;
        help.dataset.desc = currentStrings[`${key}Help`] || '';
        help.textContent = '❓';

        labelHelp.appendChild(label);
        labelHelp.appendChild(help);

        const inputDiv = document.createElement('div');
        inputDiv.className = 'ytdf-string-input';

        const input = document.createElement('input');
        input.type = 'text';
        input.id = `ytdf-${key}`;
        input.placeholder = currentStrings.inputPlaceholder;

        inputDiv.appendChild(input);
        row.appendChild(labelHelp);
        row.appendChild(inputDiv);

        return row;
    }

    const panelElement = createPanelElement();
    document.body.appendChild(panelElement);

    // ===== Settings UI Logic =====

    /**
     * Save settings to storage
     * @param {object} obj - Settings object
     */
    function saveSettings(obj) {
        GM_setValue("basic", obj);
    }

    /**
     * Set toggle state
     * @param {HTMLElement} el - Toggle element
     * @param {boolean} value - Toggle state
     */
    function setToggle(el, value) {
        el.classList.toggle('ytdf-on', !!value);
        el.setAttribute('aria-checked', !!value);
    }

    /**
     * Apply settings to UI elements
     * @param {object} settings - Settings object
     */
    function applySettingsToUI(settings) {
        const arrayToStr = (array) => Array.isArray(array) ? array.join(' ') : '';

        setToggle(document.querySelector('.ytdf-toggle[data-key="prependDatesEnabled"]'), settings.prependDatesEnabled);
        document.getElementById('ytdf-dateFormat').value = settings.dateFormat || '';
        document.getElementById('ytdf-oldUploadKeywords').value = arrayToStr(settings.oldUploadKeywords);
        document.getElementById('ytdf-dateTimeKeywords').value = arrayToStr(settings.dateTimeKeywords);
        document.getElementById('ytdf-agoKeywords').value = arrayToStr(settings.agoKeywords);
        document.getElementById('ytdf-monthNames').value = arrayToStr(settings.monthNames);
        document.getElementById('ytdf-dayNames').value = arrayToStr(settings.dayNames);
    }

    /**
     * Read settings from UI elements
     * @returns {object} Settings object
     */
    function readUIToSettings() {
        const stringToArray = (str) => str.split(' ').filter(Boolean);

        return {
            prependDatesEnabled: document.querySelector('.ytdf-toggle[data-key="prependDatesEnabled"]').classList.contains('ytdf-on'),
            dateFormat: document.getElementById('ytdf-dateFormat').value,
            oldUploadKeywords: stringToArray(document.getElementById('ytdf-oldUploadKeywords').value),
            dateTimeKeywords: stringToArray(document.getElementById('ytdf-dateTimeKeywords').value),
            agoKeywords: stringToArray(document.getElementById('ytdf-agoKeywords').value),
            monthNames: stringToArray(document.getElementById('ytdf-monthNames').value),
            dayNames: stringToArray(document.getElementById('ytdf-dayNames').value),
        };
    }

    /**
     * Attach toggle event handlers
     */
    function attachToggleHandlers() {
        document.querySelectorAll('.ytdf-toggle').forEach(toggle => {
            toggle.addEventListener('click', () => {
                const newState = !toggle.classList.contains('ytdf-on');
                setToggle(toggle, newState);
            });

            toggle.addEventListener('keydown', (ev) => {
                if (ev.key === ' ' || ev.key === 'Enter') {
                    ev.preventDefault();
                    toggle.click();
                }
            });
        });
    }

    /**
     * Attach help icon event handlers
     */
    function attachHelpHandlers() {
        document.querySelectorAll('.ytdf-string-row, .ytdf-switch-row').forEach(row => {
            const help = row.querySelector('.ytdf-help');
            if (!help) return;

            const example = help.dataset.example || '';
            const desc = help.dataset.desc || '';
            const key = row.dataset.key;

            if (!key) {
                help.setAttribute('title', desc);
                return;
            }

            const titleText = currentStrings.helpTooltip
                .replace('{desc}', desc)
                .replace('{example}', example);
            help.setAttribute('title', titleText);

            if (example) {
                help.addEventListener('click', () => {
                    const input = document.getElementById('ytdf-' + key);
                    if (input) {
                        input.value = example;
                        input.focus();
                    }
                });
            }
        });
    }

    /**
     * Show settings panel
     */
    function showPanel() {
        if (panelElement) panelElement.style.display = 'block';
    }

    /**
     * Hide settings panel
     */
    function hidePanel() {
        if (panelElement) panelElement.style.display = 'none';
    }

    // Register menu command
    GM_registerMenuCommand(currentStrings.menuTitle, showPanel);

    // Close panel on Escape key
    document.addEventListener('keydown', (ev) => {
        if (ev.key === 'Escape') hidePanel();
    });

    // Close panel on outside click
    document.addEventListener('click', (e) => {
        if (panelElement.style.display === 'block' &&
            !panelElement.contains(e.target) &&
            !e.target.closest('.GM-style-mt')) {
            hidePanel();
        }
    }, true);

    // Save button handler
    document.getElementById('ytdf-save-btn').addEventListener('click', () => {
        saveSettings(readUIToSettings());
        alert(currentStrings.alertSaved);
    });

    // Reset button handler
    document.getElementById('ytdf-reset-btn').addEventListener('click', () => {
        if (!confirm(currentStrings.confirmReset)) return;

        const settingsToSave = {};
        saveSettings(settingsToSave);
        alert(currentStrings.alertSaved);
        applySettingsToUI(settingsToSave);
    });

    // Initialize UI
    try {
        applySettingsToUI(SETTINGS);
        attachToggleHandlers();
        attachHelpHandlers();
    } catch (e) {
        console.error('[YouTube Date Formatter] Error initializing UI:', e);
    }

})();