YouTube Video Fade In/Out

Fade in/out audio when playing/pausing YouTube videos (optionally music videos only)

// ==UserScript==
// @name         YouTube Video Fade In/Out
// @namespace    http://tampermonkey.net/
// @version      1.001
// @description  Fade in/out audio when playing/pausing YouTube videos (optionally music videos only)
// @author       You
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @match        https://music.youtube.com/*
// @grant        none
// @run-at       document-start
// @license MIT 
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const MUSIC_VIDEOS_ONLY = false; // Set to true to only apply to music videos
    const FADE_DURATION_MS = 1000;   // Total fade duration in milliseconds
    const FADE_STEPS = 10;           // Number of fade steps

    console.log("[YT-FADER] Tampermonkey script loading...");

    function isMusicVideo() {
        // Always true for YouTube Music
        if (window.location.hostname === 'music.youtube.com') {
            return true;
        }

        // For regular YouTube, check multiple indicators
        const musicIndicators = [
            // Title patterns (common in music videos)
            () => {
                const title = document.title.toLowerCase();
                return title.includes('official music video') ||
                       title.includes('official video') ||
                       title.includes('official audio') ||
                       title.includes('lyric video') ||
                       title.includes('music video') ||
                       title.match(/\b(ft\.?|feat\.?|featuring)\b/) ||
                       title.match(/\b(remix|cover|acoustic|live)\b/) ||
                       title.match(/\([^)]*(?:official|audio|video|lyrics?)[^)]*\)/);
            },

            // Check for music category in YouTube's data
            () => {
                const categoryElements = document.querySelectorAll('[data-content-category]');
                return Array.from(categoryElements).some(el =>
                    el.dataset.contentCategory === '10' // YouTube music category ID
                );
            },

            // Channel indicators (record labels, VEVO, etc.)
            () => {
                const channelName = document.querySelector('#text.ytd-channel-name, .ytd-channel-name #text')?.textContent?.toLowerCase() || '';
                return channelName.includes('records') ||
                       channelName.includes('music') ||
                       channelName.includes('vevo') ||
                       channelName.includes('official') ||
                       channelName.endsWith('topic'); // YouTube auto-generated artist channels
            },

            // Video description patterns
            () => {
                const description = document.querySelector('#description-text, .content')?.textContent?.toLowerCase() || '';
                return description.includes('stream') ||
                       description.includes('spotify') ||
                       description.includes('apple music') ||
                       description.includes('itunes') ||
                       description.includes('follow me') ||
                       description.match(/(?:lyrics?|directed by|produced by)/);
            },

            // Check for music-related hashtags
            () => {
                const hashtags = Array.from(document.querySelectorAll('a[href*="/hashtag/"]'))
                    .map(el => el.textContent.toLowerCase());
                return hashtags.some(tag =>
                    tag.includes('music') ||
                    tag.includes('song') ||
                    tag.includes('artist') ||
                    tag.includes('newmusic') ||
                    tag.includes('musicvideo')
                );
            },

            // Duration check (most music videos are 2-8 minutes)
            () => {
                const durationText = document.querySelector('.ytp-time-duration')?.textContent;
                if (durationText) {
                    const parts = durationText.split(':').map(Number);
                    const totalSeconds = parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
                    return totalSeconds >= 120 && totalSeconds <= 480; // 2-8 minutes
                }
                return false;
            }
        ];

        // Consider it a music video if at least 2 indicators match
        const matches = musicIndicators.filter(check => {
            try {
                return check();
            } catch (e) {
                return false;
            }
        }).length;

        return matches >= 2;
    }

    function injectFadeScript() {
        const code = `
            (function() {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                const FADE_DURATION = ${FADE_DURATION_MS};
                const FADE_STEPS = ${FADE_STEPS};
                const MUSIC_ONLY = ${MUSIC_VIDEOS_ONLY};

                // Check if we should apply fading
                function shouldApplyFade() {
                    if (!MUSIC_ONLY) return true;

                    // Re-check music status when play/pause is triggered
                    return ${isMusicVideo.toString()}();
                }

                const realPause = HTMLMediaElement.prototype.pause;
                const realPlay  = HTMLMediaElement.prototype.play;

                HTMLMediaElement.prototype.play = async function() {
                    console.log("[YT-FADER] play() called, paused:", this.paused, "tagName:", this.tagName, "current volume:", this.volume);

                    if (this.tagName !== "VIDEO" || (MUSIC_ONLY && !shouldApplyFade())) {
                        console.log("[YT-FADER] play() - skipping fade, calling realPlay()");
                        return realPlay.apply(this, arguments);
                    }

                    // Prevent multiple fade operations
                    if (this.dataset.fadingIn === 'true') {
                        console.log("[YT-FADER] play() - already fading in, calling realPlay() directly");
                        return realPlay.apply(this, arguments);
                    }

                    // Only store preferred volume if we haven't stored one yet AND the current volume isn't 0
                    let pref;
                    if (this.dataset.maxVolume) {
                        pref = parseFloat(this.dataset.maxVolume);
                        console.log("[YT-FADER] Using stored preferred volume:", pref);
                    } else if (this.volume > 0) {
                        // Use current volume as preferred if it's not 0 (from previous fade)
                        pref = this.volume;
                        this.dataset.maxVolume = pref.toString();
                        console.log("[YT-FADER] Storing current volume as preferred:", pref);
                    } else {
                        // Fallback to a reasonable default if volume is 0
                        pref = 0.5;
                        this.dataset.maxVolume = pref.toString();
                        console.log("[YT-FADER] Using fallback volume:", pref);
                    }

                    this.dataset.fadingIn = 'true';

                    console.log("[YT-FADER] Starting fade-in to preferred volume:", pref.toFixed(2));

                    // Start at 0 volume and play
                    this.volume = 0;
                    const playPromise = realPlay.apply(this, arguments);

                    // Fade in to preferred volume
                    const stepDelay = FADE_DURATION / FADE_STEPS;
                    for (let step = 1; step <= FADE_STEPS; step++) {
                        const v = (pref * step) / FADE_STEPS;
                        this.volume = Math.min(v, 1);
                        console.log("[YT-FADER] fade-in step", step, "volume:", v.toFixed(2));
                        await sleep(stepDelay);
                    }

                    this.dataset.fadingIn = 'false';
                    console.log("[YT-FADER] Fade-in complete");
                    return playPromise;
                };

                HTMLMediaElement.prototype.pause = async function() {
                    console.log("[YT-FADER] pause() called, paused:", this.paused, "tagName:", this.tagName, "current volume:", this.volume);

                    if (this.tagName !== "VIDEO" || this.paused || (MUSIC_ONLY && !shouldApplyFade())) {
                        console.log("[YT-FADER] pause() - skipping fade, calling realPause()");
                        return realPause.apply(this, arguments);
                    }

                    // Prevent multiple fade operations
                    if (this.dataset.fadingOut === 'true') {
                        console.log("[YT-FADER] pause() - already fading out, calling realPause() directly");
                        return realPause.apply(this, arguments);
                    }

                    // Only store current volume as preferred if we don't have one stored yet
                    // and the current volume is reasonable (not 0 from a previous fade)
                    if (!this.dataset.maxVolume && this.volume > 0.1) {
                        this.dataset.maxVolume = this.volume.toString();
                        console.log("[YT-FADER] Storing current volume as preferred:", this.volume.toFixed(2));
                    }

                    this.dataset.fadingOut = 'true';
                    const currentVol = this.volume;

                    console.log("[YT-FADER] Starting fade-out from volume:", currentVol.toFixed(2));

                    // Fade out from current volume to 0
                    const stepDelay = FADE_DURATION / FADE_STEPS;
                    for (let step = FADE_STEPS - 1; step >= 0; step--) {
                        const v = (currentVol * step) / FADE_STEPS;
                        this.volume = Math.max(v, 0);
                        console.log("[YT-FADER] fade-out step", (FADE_STEPS - step), "volume:", v.toFixed(2));
                        await sleep(stepDelay);
                    }

                    this.dataset.fadingOut = 'false';
                    console.log("[YT-FADER] Fade-out complete, calling realPause()");
                    return realPause.apply(this, arguments);
                };

                console.log("[YT-FADER] Fade overrides installed", MUSIC_ONLY ? "(music videos only)" : "(all videos)");
            })();
        `;

        const script = document.createElement("script");
        script.textContent = code;
        (document.head || document.documentElement).appendChild(script);
        script.remove();
    }

    // Inject immediately if DOM is ready, otherwise wait
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', injectFadeScript);
    } else {
        injectFadeScript();
    }

    // Also inject on navigation changes (YouTube is a SPA)
    let lastUrl = location.href;
    new MutationObserver(() => {
        const currentUrl = location.href;
        if (currentUrl !== lastUrl) {
            lastUrl = currentUrl;
            // Small delay to let YouTube load
            setTimeout(injectFadeScript, 500);
        }
    }).observe(document, { subtree: true, childList: true });

})();