YouTube Enhancer (Reveal Views & Upload Time)

Reveal Views & Upload Time for videos and Shorts.

// ==UserScript==
// @name         YouTube Enhancer (Reveal Views & Upload Time)
// @description  Reveal Views & Upload Time for videos and Shorts.
// @icon         https://raw.githubusercontent.com/exyezed/youtube-enhancer/refs/heads/main/extras/youtube-enhancer.png
// @version      1.4
// @author       exyezed
// @namespace    https://github.com/exyezed/youtube-enhancer/
// @supportURL   https://github.com/exyezed/youtube-enhancer/issues
// @license      MIT
// @match        https://www.youtube.com/*
// ==/UserScript==

(function() {
    'use strict';    
    const badgeStyles = `
        @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200');
        
        #secondary-inner .revealViewsAndUploadTime {
            height: 36px;
            font-size: 14px;
            font-weight: 500;
            border-radius: 8px;
            padding: 0 16px;
            font-family: inherit;
            border: 1px solid transparent;
            margin-bottom: 10px;
            width: 100%;
            box-sizing: border-box;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            cursor: pointer;
        }

        html[dark] #secondary-inner .revealViewsAndUploadTime {
            background-color: #ffffff1a;
            color: var(--yt-spec-text-primary, #fff);
        }

        html:not([dark]) #secondary-inner .revealViewsAndUploadTime {
            background-color: #0000000d;
            color: var(--yt-spec-text-primary, #030303);
        }

        html[dark] #secondary-inner .revealViewsAndUploadTime:hover {
            background-color: #ffffff33;
        }

        html:not([dark]) #secondary-inner .revealViewsAndUploadTime:hover {
            background-color: #00000014;
        }

        #secondary-inner .revealViewsAndUploadTime .separator {
            margin: 0 2px;
            width: 1px;
            height: 24px;
            opacity: 0.3;
        }

        html[dark] #secondary-inner .revealViewsAndUploadTime .separator {
            background-color: var(--yt-spec-text-secondary, #aaa);
        }

        html:not([dark]) #secondary-inner .revealViewsAndUploadTime .separator {
            background-color: var(--yt-spec-text-secondary, #606060);
        }
            
        .shorts-upload-date-injected {
            color: rgb(255, 255, 255);
            font-family: "Roboto", Arial, sans-serif;
            font-size: 14px;
            font-weight: 400;
            margin-bottom: 8px;
            padding: 4px 12px;
            opacity: 0.8;
            background-color: rgba(0, 0, 0, 0.6);
            border-radius: 4px;
            backdrop-filter: blur(8px);
            width: fit-content;
            max-width: 90%;
            word-wrap: break-word;
            display: flex;
            align-items: center;
            gap: 6px;
        }
          
        .shorts-upload-date-injected .material-symbols-outlined {
            font-size: 18px;
            opacity: 0.9;
        }

        .shorts-views-injected {
            color: rgb(255, 255, 255);
            font-family: "Roboto", Arial, sans-serif;
            font-size: 14px;
            font-weight: 400;
            margin-bottom: 8px;
            padding: 4px 12px;
            opacity: 0.8;
            background-color: rgba(0, 0, 0, 0.6);
            border-radius: 4px;
            backdrop-filter: blur(8px);
            width: fit-content;
            max-width: 90%;
            word-wrap: break-word;
            display: flex;
            align-items: center;
            gap: 6px;
        }
          
        .shorts-views-injected .material-symbols-outlined {
            font-size: 18px;
            opacity: 0.9;
        }

        .shorts-age-injected {
            color: rgb(255, 255, 255);
            font-family: "Roboto", Arial, sans-serif;
            font-size: 14px;
            font-weight: 400;
            margin-bottom: 8px;
            padding: 4px 12px;
            opacity: 0.8;
            background-color: rgba(0, 0, 0, 0.6);
            border-radius: 4px;
            backdrop-filter: blur(8px);
            width: fit-content;
            max-width: 90%;
            word-wrap: break-word;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        
        .shorts-age-injected .material-symbols-outlined {
            font-size: 18px;
            opacity: 0.9;
        }

        .material-symbols-outlined {
            font-size: 24px;
            line-height: 1;
            font-variation-settings:
                'FILL' 0,
                'wght' 150,
                'GRAD' 0,
                'opsz' 24;
        }
    `;

    function createBadge(viewCount, uploadTime, uploadDate) {
        const badge = document.createElement('div');
        badge.className = 'revealViewsAndUploadTime';

        const mainIcon = document.createElement('span');
        mainIcon.className = 'material-symbols-outlined';
        mainIcon.textContent = 'visibility';

        const dataSpan = document.createElement('span');
        dataSpan.textContent = viewCount;

        const separator = document.createElement('div');
        separator.className = 'separator';

        const timeIcon = document.createElement('span');
        timeIcon.className = 'material-symbols-outlined';
        timeIcon.textContent = 'schedule';

        const timeSpan = document.createElement('span');
        timeSpan.textContent = uploadTime;

        badge.appendChild(mainIcon);
        badge.appendChild(dataSpan);
        badge.appendChild(separator);
        badge.appendChild(timeIcon);
        badge.appendChild(timeSpan);

        let isShowingViews = true;
        badge.addEventListener('click', () => {
            if (isShowingViews) {
                mainIcon.textContent = 'calendar_month';
                dataSpan.textContent = uploadDate;
                timeIcon.textContent = 'schedule';
                timeIcon.style.display = '';
            } else {
                mainIcon.textContent = 'visibility';
                dataSpan.textContent = viewCount;
                timeIcon.textContent = 'schedule';
                timeIcon.style.display = '';
            }
            isShowingViews = !isShowingViews;
        });

        return badge;
    }    
    
    function getVideoId() {
        const urlObj = new URL(window.location.href);
        if (urlObj.pathname.includes('/watch')) {
            return urlObj.searchParams.get('v');
        } else if (urlObj.pathname.includes('/video/')) {
            return urlObj.pathname.split('/video/')[1];
        } else if (urlObj.pathname.includes('/shorts/')) {
            return urlObj.pathname.split('/shorts/')[1];
        }
        return null;
    }

    function isOnShortsPage() {
        return window.location.pathname.includes('/shorts/');
    }

    function formatNumber(number) {
        return new Intl.NumberFormat('en-US').format(number);
    }    
    
    function formatDate(dateString) {
        const date = new Date(dateString);
        const today = new Date();
        const yesterday = new Date(today);
        yesterday.setDate(yesterday.getDate() - 1);

        if (date.toDateString() === today.toDateString()) {
            return 'Today';
        } else if (date.toDateString() === yesterday.toDateString()) {
            return 'Yesterday';
        } else {
            const options = {
                weekday: 'long',
                day: '2-digit',
                month: '2-digit',
                year: 'numeric',
            };
            const formattedDate = new Intl.DateTimeFormat('en-GB', options).format(date);
            const [dayName, datePart] = formattedDate.split(', ');
            return `${dayName}, ${datePart.replace(/\//g, '/')}`;
        }
    }    
    
    function formatDateForShorts(dateString) {
        const date = new Date(dateString);
        const dateOptions = {
            day: '2-digit',
            month: 'long',
            year: 'numeric'
        };
        const timeOptions = {
            hour: '2-digit',
            minute: '2-digit',
            hour12: false
        };
        
        const formattedDate = new Intl.DateTimeFormat('en-GB', dateOptions).format(date);
        const formattedTime = new Intl.DateTimeFormat('en-GB', timeOptions).format(date);
        
        return `${formattedDate} • ${formattedTime}`;
    }

    function formatUploadAge(dateString) {
        const uploadDate = new Date(dateString);
        const now = new Date();
        const diffInMs = now - uploadDate;
        
        const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
        const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
        const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
        const diffInWeeks = Math.floor(diffInDays / 7);
        const diffInMonths = Math.floor(diffInDays / 30);
        const diffInYears = Math.floor(diffInDays / 365);
        
        if (diffInYears > 0) {
            return `${diffInYears}y ago`;
        } else if (diffInMonths > 0) {
            return `${diffInMonths}mo ago`;
        } else if (diffInWeeks > 0) {
            return `${diffInWeeks}w ago`;
        } else if (diffInDays > 0) {
            const remainingHours = diffInHours % 24;
            if (remainingHours > 0) {
                return `${diffInDays}d ${remainingHours}h ago`;
            } else {
                return `${diffInDays}d ago`;
            }
        } else if (diffInHours > 0) {
            const remainingMinutes = diffInMinutes % 60;
            if (remainingMinutes > 0) {
                return `${diffInHours}h ${remainingMinutes}m ago`;
            } else {
                return `${diffInHours}h ago`;
            }
        } else if (diffInMinutes > 0) {
            return `${diffInMinutes}m ago`;
        } else {
            return 'Just now';
        }
    }

    function formatTime(dateString) {
        const date = new Date(dateString);
        const options = {
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
            hour12: false
        };
        return new Intl.DateTimeFormat('en-GB', options).format(date);
    }

    function getApiKey() {
        const scripts = document.getElementsByTagName('script');
        for (const script of scripts) {
            const match = script.textContent.match(/"INNERTUBE_API_KEY":\s*"([^"]+)"/);
            if (match && match[1]) return match[1];
        }
        return null;
    }

    function getClientInfo() {
        const scripts = document.getElementsByTagName('script');
        let clientName = null;
        let clientVersion = null;
        
        for (const script of scripts) {
            const nameMatch = script.textContent.match(/"INNERTUBE_CLIENT_NAME":\s*"([^"]+)"/);
            const versionMatch = script.textContent.match(/"INNERTUBE_CLIENT_VERSION":\s*"([^"]+)"/);
            
            if (nameMatch && nameMatch[1]) clientName = nameMatch[1];
            if (versionMatch && versionMatch[1]) clientVersion = versionMatch[1];
        }
        
        return { clientName, clientVersion };
    }

    async function fetchVideoInfo(videoId) {
        try {
            const apiKey = getApiKey();
            if (!apiKey) return null;
            
            const { clientName, clientVersion } = getClientInfo();
            if (!clientName || !clientVersion) return null;
            
            const response = await fetch(`https://www.youtube.com/youtubei/v1/player?key=${apiKey}`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    videoId: videoId,
                    context: {
                        client: {
                            clientName: clientName,
                            clientVersion: clientVersion,
                        }
                    }
                })
            });
            
            if (!response.ok) return null;
            const data = await response.json();
            
            let viewCount = "Unknown";
            if (data.videoDetails?.viewCount) {
                viewCount = formatNumber(data.videoDetails.viewCount);
            }
            
            let publishDate = "Unknown";
            if (data.microformat?.playerMicroformatRenderer?.publishDate) {
                publishDate = data.microformat.playerMicroformatRenderer.publishDate;
            }
            
            return {
                viewCount,
                uploadDate: publishDate
            };
        } catch (error) {
            return null;
        }
    }

    function updateBadge(viewCount, uploadTime, uploadDate) {
        let badge = document.querySelector('.revealViewsAndUploadTime');
        if (badge) {
            badge.remove();
        }
        insertBadge(viewCount, uploadTime, uploadDate);
    }

    function insertBadge(viewCount, uploadTime, uploadDate) {
        const targetElement = document.querySelector('#secondary-inner #panels');
        if (targetElement && !document.querySelector('.revealViewsAndUploadTime')) {
            const badge = createBadge(viewCount, uploadTime, uploadDate);
            targetElement.parentNode.insertBefore(badge, targetElement);
        }
    }    
    
    function addStyles() {
        if (!document.querySelector('#revealViewsAndUploadTime-styles')) {
            const styleElement = document.createElement('style');
            styleElement.id = 'revealViewsAndUploadTime-styles';
            styleElement.textContent = badgeStyles;
            document.head.appendChild(styleElement);
        }
    }

    const state = {
        currentVideoId: null,
        injectionInProgress: false,
        shortsObserver: null,
        lastUrl: location.href,
        wasOnShortsPage: isOnShortsPage()
    };

    const SHORTS_CONFIG = {
        className: 'shorts-upload-date-injected',
        viewsClassName: 'shorts-views-injected',
        ageClassName: 'shorts-age-injected',
        selectors: {
            metapanel: '#metapanel, .ytReelMetapanelViewModelHost, yt-reel-metapanel-view-model'
        }
    };

    const utils = {
        log: (message, type = 'info') => {
            const prefix = '[YouTube Enhancer]';
            console[type](`${prefix} ${message}`);
        },
        
        querySelector: (selectors) => {
            return selectors.split(', ').reduce((found, selector) => {
                return found || document.querySelector(selector.trim());
            }, null);
        },

        debounce: (func, wait) => {
            let timeout;
            return function executedFunction(...args) {
                const later = () => {
                    clearTimeout(timeout);
                    func(...args);
                };
                clearTimeout(timeout);
                timeout = setTimeout(later, wait);
            };
        }
    };

    const createElement = (type, className, iconName, text) => {
        const element = document.createElement('div');
        element.className = className;
        
        const icon = document.createElement('span');
        icon.className = 'material-symbols-outlined';
        icon.textContent = iconName;
        
        const textSpan = document.createElement('span');
        textSpan.textContent = text;
        
        element.appendChild(icon);
        element.appendChild(textSpan);
        
        return element;
    };

    const createShortsUploadElement = (uploadDate) => createElement('shorts', SHORTS_CONFIG.className, 'calendar_clock', uploadDate);
    const createShortsViewsElement = (viewCount) => createElement('shorts', SHORTS_CONFIG.viewsClassName, 'visibility', viewCount);
    const createShortsAgeElement = (uploadAge) => createElement('shorts', SHORTS_CONFIG.ageClassName, 'schedule', uploadAge);
    const getShortsElements = () => ({
        upload: document.querySelector(`.${SHORTS_CONFIG.className}`),
        views: document.querySelector(`.${SHORTS_CONFIG.viewsClassName}`),
        age: document.querySelector(`.${SHORTS_CONFIG.ageClassName}`)
    });

    const isShortsAlreadyInjected = () => getShortsElements().upload !== null;    const injectShortsUploadDate = async () => {
        const videoId = getVideoId();
        
        if (!videoId) {
            utils.log('No video ID found', 'warn');
            return false;
        }
        
        if (state.injectionInProgress && state.currentVideoId === videoId) {
            utils.log('Injection already in progress for this video');
            return false;
        }
        
        const targetElement = utils.querySelector(SHORTS_CONFIG.selectors.metapanel);
        
        if (!targetElement) {
            utils.log('Target element not found, retrying...', 'warn');
            setTimeout(() => injectShortsUploadDate(), 200);
            return false;
        }

        if (state.currentVideoId === videoId && isShortsAlreadyInjected()) {
            utils.log('Elements already exist for current video');
            return true;
        }
        
        if (state.currentVideoId !== videoId) {
            cleanupShortsElements();
            state.currentVideoId = videoId;
        }
        
        if (!isShortsAlreadyInjected()) {
            const uploadElement = createShortsUploadElement('Loading...');
            const ageElement = createShortsAgeElement('Loading...');
            const viewsElement = createShortsViewsElement('Loading...');
            
            targetElement.insertBefore(uploadElement, targetElement.firstChild);
            targetElement.insertBefore(ageElement, uploadElement.nextSibling);
            targetElement.insertBefore(viewsElement, ageElement.nextSibling);
            
            utils.log('Loading elements created');
        }
        
        state.injectionInProgress = true;

        try {
            const videoInfo = await fetchVideoInfo(videoId);
            const elements = getShortsElements();
            
            if (videoInfo && videoInfo.uploadDate !== "Unknown") {
                if (elements.upload) {
                    const formattedDate = formatDateForShorts(videoInfo.uploadDate);
                    elements.upload.querySelector('span:last-child').textContent = formattedDate;
                }
                
                if (elements.age) {
                    const uploadAge = formatUploadAge(videoInfo.uploadDate);
                    elements.age.querySelector('span:last-child').textContent = uploadAge;
                }
                
                if (elements.views && videoInfo.viewCount && videoInfo.viewCount !== "Unknown") {
                    elements.views.querySelector('span:last-child').textContent = videoInfo.viewCount;
                }
                
                utils.log('Upload date and views successfully updated');
                state.injectionInProgress = false;
                return true;
            } else {
                Object.values(elements).forEach(element => {
                    if (element) element.querySelector('span:last-child').textContent = 'Error';
                });
                
                utils.log('Could not fetch video info', 'warn');
                state.injectionInProgress = false;
                return false;
            }
        } catch (error) {
            const elements = getShortsElements();
            Object.values(elements).forEach(element => {
                if (element) element.querySelector('span:last-child').textContent = 'Error';
            });
            
            utils.log('Error fetching video info: ' + error.message, 'error');
            state.injectionInProgress = false;
            return false;
        }
    };    
    
    const handleShortsMutations = utils.debounce((mutations) => {
        const videoId = getVideoId();
        if (!videoId) return;
        
        let shouldInject = false;
        
        if (state.currentVideoId !== videoId) {
            shouldInject = true;
        } else {
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            const hasMetapanel = node.matches?.(SHORTS_CONFIG.selectors.metapanel.split(', ')[0]) ||
                                               node.querySelector?.(SHORTS_CONFIG.selectors.metapanel);
                            if (hasMetapanel) shouldInject = true;
                        }
                    });
                }
            });
        }
        
        if (shouldInject) {
            setTimeout(() => injectShortsUploadDate(), 200);
        }
    }, 100);    
    
    const startShortsObserver = () => {
        if (state.shortsObserver) return;
        
        state.shortsObserver = new MutationObserver(handleShortsMutations);
        state.shortsObserver.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: false
        });
        
        utils.log('MutationObserver started');
    };

    const stopShortsObserver = () => {
        if (state.shortsObserver) {
            state.shortsObserver.disconnect();
            state.shortsObserver = null;
            utils.log('MutationObserver stopped');
        }
    };

    const cleanupShortsElements = () => {
        const selectors = [
            `.${SHORTS_CONFIG.className}`,
            `.${SHORTS_CONFIG.viewsClassName}`,
            `.${SHORTS_CONFIG.ageClassName}`
        ];
        
        let cleanedCount = 0;
        selectors.forEach(selector => {
            const elements = document.querySelectorAll(selector);
            elements.forEach(element => element.remove());
            cleanedCount += elements.length;
        });
        
        utils.log(`Cleaned up ${cleanedCount} existing elements`);
        state.currentVideoId = null;
        state.injectionInProgress = false;
    };

    const initShortsFeature = () => {
        utils.log('Starting Shorts Upload Date Feature');
        
        state.injectionInProgress = false;
        state.currentVideoId = null;
        
        cleanupShortsElements();
        setTimeout(() => injectShortsUploadDate(), 300);
        startShortsObserver();
    };

    const stopShortsFeature = () => {
        utils.log('Stopping Shorts Upload Date Feature');
        stopShortsObserver();
        cleanupShortsElements();
    };
    
    async function updateBadgeWithInfo(videoId) {
        updateBadge('Loading...', 'Loading...', 'Loading...');
        
        try {
            const videoInfo = await fetchVideoInfo(videoId);
            if (videoInfo) {
                const uploadTime = formatTime(videoInfo.uploadDate);
                const formattedUploadDate = formatDate(videoInfo.uploadDate);
                updateBadge(videoInfo.viewCount, uploadTime, formattedUploadDate);
            } else {
                updateBadge('Error', 'Error', 'Error');
            }
        } catch (error) {
            updateBadge('Error', 'Error', 'Error');
        }
    }
    
    function init() {
        addStyles();
        
        if (isOnShortsPage()) {
            initShortsFeature();
        } else {
            const videoId = getVideoId();
            if (videoId) {
                updateBadgeWithInfo(videoId);
            } else {
                updateBadge('N/A', 'N/A', 'N/A');
            }
        }
    }        
    
    function observePageChanges() {
        let lastVideoId = getVideoId();

        const observer = new MutationObserver(() => {
            if (location.href !== state.lastUrl) {
                state.lastUrl = location.href;
                const currentVideoId = getVideoId();
                const isCurrentlyOnShortsPage = isOnShortsPage();
                
                if (state.wasOnShortsPage !== isCurrentlyOnShortsPage) {
                    if (state.wasOnShortsPage) {
                        stopShortsFeature();
                    } else {
                        updateBadge('', '', '');
                    }
                    state.wasOnShortsPage = isCurrentlyOnShortsPage;
                    lastVideoId = null;
                }
                
                if (isCurrentlyOnShortsPage) {
                    if (currentVideoId && currentVideoId !== lastVideoId) {
                        lastVideoId = currentVideoId;
                        state.injectionInProgress = false;
                        setTimeout(() => injectShortsUploadDate(), 300);
                    }
                } else {
                    if (currentVideoId && currentVideoId !== lastVideoId) {
                        lastVideoId = currentVideoId;
                        updateBadgeWithInfo(currentVideoId);
                    } else if (!currentVideoId) {
                        updateBadge('Not a video', 'Not a video', 'Not a video');
                    }
                }
            }
        });

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

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            init();
            observePageChanges();
        });
    } else {
        init();
        observePageChanges();
    }    
    
    window.addEventListener('yt-navigate-start', function() {
        if (isOnShortsPage()) {
            cleanupShortsElements();
        } else {
            updateBadge('Loading...', 'Loading...', 'Loading...');
        }
    });    
    
    window.addEventListener('yt-navigate-finish', function() {
        setTimeout(() => {
            if (isOnShortsPage()) {
                state.injectionInProgress = false;
                setTimeout(() => injectShortsUploadDate(), 300);
            } else {
                const videoId = getVideoId();
                if (videoId) {
                    updateBadgeWithInfo(videoId);
                } else {
                    updateBadge('Not a video', 'Not a video', 'Not a video');
                }
            }
        }, 100);
    });
})();