Youtube subtitles under video frame

Have you ever been annoyed by youtube subtitles covering some important part of the video? No more! The userscript moves subtitles under video frame (but you can still drag-move them horizontally). It works for default and theater modes.

Install this script?
Author's suggested script

You may also like Youtube sticky Show Less button.

Install this script
/* 
    Youtube subtitles under video frame: Move youtube subtitles under video frame.
	Copyright (C) 2023  T1mL3arn

    This program is free software: you can redistribute it and/or modify 
    it under the terms of the GNU General Public License as published by 
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version. 
	
    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

// ==UserScript==
// @name        Youtube subtitles under video frame 
// @name:RU     Субтитры Youtube под видео
// @description Have you ever been annoyed by youtube subtitles covering some important part of the video? No more! The userscript moves subtitles under video frame (but you can still drag-move them horizontally). It works for default and theater modes. 
// @description:RU  Вам когда-нибудь мешали субтитры Youtube, закрывыющие какую-то важную область видео? Пора это прекратить! Этот скрипт сдвигает субтитры под видео (вы все еще можете перетаскивать их по горизонтали). Работает в режимах "обычный" и "широкий экран".
// @namespace   https://github.com/t1ml3arn-userscript-js
// @version     1.5.1
// @match       https://www.youtube.com/*
// @match       https://youtube.com/*
// @grant       none
// @noframes
// @author      T1mL3arn
// @icon        data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PGRlZnM+PHN5bWJvbCBpZD0iYSIgdmlld0JveD0iMCAwIDM0NCA1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHJlY3QgZmlsbD0icmdiYSgyNTUsIDI1NSwgMjU1LCAwKSIgaGVpZ2h0PSIxMDAlIiB3aWR0aD0iMTAwJSIvPjxwYXRoIGQ9Ik0tNTAtNTBINTBWNTBILTUwVi01MHoiIGZpbGw9IiNGRkYiIGZpbGwtb3BhY2l0eT0iMCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTAgNTApIi8+PHBhdGggZD0iTS01MC01MEg1MFY1MEgtNTBWLTUweiIgZmlsbD0iI0ZGRiIgZmlsbC1vcGFjaXR5PSIwIiB0cmFuc2Zvcm09Im1hdHJpeCguNDUgMCAwIC43NiAxNTYuNDEgMzY5Ljc1KSIvPjxwYXRoIGQ9Ik0tNTAtNTBWNTBINTBWLTUwSC01MHoiIGZpbGw9IiNGRkYiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIwIiB0cmFuc2Zvcm09Im1hdHJpeCgyLjQxIDAgMCAuMiAxMjAuNSAxMCkiLz48cGF0aCBkPSJNLTUwLTUwVjUwSDUwVi01MEgtNTB6IiBmaWxsPSIjRkZGIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMCIgdHJhbnNmb3JtPSJtYXRyaXgoLjcgMCAwIC4yIDMxMC4xOCAxMCkiLz48cGF0aCBkPSJNLTUwLTUwVjUwSDUwVi01MEgtNTB6IiBmaWxsPSIjRkZGIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMCIgdHJhbnNmb3JtPSJtYXRyaXgoLTIuNDEgMCAwIC0uMiAyMjQuNjggNTApIi8+PHBhdGggZD0iTS01MC01MFY1MEg1MFYtNTBILTUweiIgZmlsbD0iI0ZGRiIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjAiIHRyYW5zZm9ybT0ibWF0cml4KC0uNyAwIDAgLS4yIDM1IDUwKSIvPjwvc3ltYm9sPjwvZGVmcz48ZyBjbGFzcz0ibGF5ZXIiPjxwYXRoIGQ9Ik00OTguMjk0IDU3LjI5OWMtNS44MjgtMjEuNzc2LTIyLjk0Mi0zOC44ODgtNDQuNzE4LTQ0LjcxN0M0MTQuMTUgMi4wMDYgMjU1Ljk3MiAyLjAwNiAyNTUuOTcyIDIuMDA2cy0xNTguMTc0IDAtMTk3LjYwMyAxMC41NzZDMzYuNTkzIDE4LjQxIDE5LjQ4IDM1LjUyMyAxMy42NTIgNTcuMjk5IDMuMDc2IDk2LjcyOCAzLjA3NiAxNzkuMDQyIDMuMDc2IDE3OS4wNDJzMCA4Mi4zMTUgMTAuNTc2IDEyMS43NDRjNS44MjkgMjEuNzc2IDIyLjk0MSAzOC44ODggNDQuNzE3IDQ0LjcxNiAzOS40MjkgMTAuNTc2IDE5Ny42MDMgMTAuNTc2IDE5Ny42MDMgMTAuNTc2czE1OC4xNzcgMCAxOTcuNjA0LTEwLjU3NmMyMS43NzYtNS44MjggMzguODktMjIuOTQgNDQuNzE4LTQ0LjcxNiAxMC41NzYtMzkuNDMgMTAuNTc2LTEyMS43NDQgMTAuNTc2LTEyMS43NDRzLS4wNDItODIuMzE0LTEwLjU3Ni0xMjEuNzQzeiIgZmlsbD0icmVkIi8+PHBhdGggZD0iTTAgLjVoNTEydjUxMkgwVi41eiIgZmlsbD0idHJhbnNwYXJlbnQiLz48cGF0aCBkPSJNNDMuNzg4IDM3MWg0MjQuNDI3djEzMS4zMkg0My43ODhWMzcxeiIvPjx1c2UgdHJhbnNmb3JtPSJtYXRyaXgoLjczNjA4IDAgMCAuOTYyMTcgLTc1Ljk5IC00ODUuMDY1KSIgeD0iMTk0LjE0NCIgeGxpbms6aHJlZj0iI2EiIHk9IjcwMiIvPjxwYXRoIGQ9Im0yMDUuMzQ1IDI1NS4wODYgMTMxLjQwNC03NS44Ni0xMzEuNDA0LTc1Ljg2djE1MS43MnoiIGZpbGw9IiNGRkYiLz48L2c+PC9zdmc+
// @homepageURL https://github.com/t1ml3arn-userscript-js/Youtube-subtitles-under-video-frame
// @supportURL  https://github.com/t1ml3arn-userscript-js/Youtube-subtitles-under-video-frame/issues
// @license     GPL-3.0-or-later
// ==/UserScript==

const SUBS_BUTTON_SELECTOR = '.ytp-subtitles-button'
const USERJS_ELT_CLASS = 'yfms-userjs'
const USERJS_STYLE_ID = 'youtube-subs-under-video-css'
const PLAYER_ELT_SELECTOR = 'ytd-watch-flexy'
const SUBS_GAP = 64;
const SUBS_GAP_THEATER = 100;
const KEY__PLAYER_CAPTION_DISPLAY_SETTINGS = 'yt-player-caption-display-settings'
const KEY__PLAYER_STICKY_CAPTION = 'yt-player-sticky-caption'

const USERJS_STYLE_CONTENT = `
.${USERJS_ELT_CLASS} {
    --subs-gap: ${SUBS_GAP}px;
    --subs-gap-theater: ${SUBS_GAP_THEATER}px;
}

.${USERJS_ELT_CLASS}:not([fullscreen]) .caption-window.ytp-caption-window-bottom {
    margin-bottom: 0 !important;
    margin-top: 0 !important;
    position: absolute !important;
    bottom: 0 !important;
    top: calc(100% + 16px) !important;
    z-index: 9999 !important;
}

ytd-player:not([fullscreen]):not([theater]) {
    /*  in default mode "ytd-player" has "overflow: hidden" thus hiding the subs, 
    this rule makes it visible again */
    overflow: visible !important;
}

.${USERJS_ELT_CLASS} .html5-video-player {
    /* to make subs visible when they are outside player frame */
    overflow: visible;
    /* to make player to be on top 
    (combined with captions z-index rule, 
    it places captions over any element on the page) */
    z-index: 999;
}

.${USERJS_ELT_CLASS} #movie_player.ended-mode .html5-video-container,
.${USERJS_ELT_CLASS} #movie_player.unstarted-mode .html5-video-container {
    /*  By default this container has no height, 
    setting height explicitly prevents hiding of video.
    This actually not needed since "overflow: hidden" happens
    before start and after end, but I want to be sure. */
    height: 100%;
    /* video frame move above when video ends, without hiding
    a user can see part of the video above player */
    overflow: hidden;
}

.${USERJS_ELT_CLASS} #below {
    margin-top: var(--subs-gap);
    transition: margin-top 0.25s;
}

.${USERJS_ELT_CLASS}[theater]:not([fullscreen]) #below {
    margin-top: var(--subs-gap-theater);
}

/* styling for "related videos" section */
.${USERJS_ELT_CLASS}[theater]:not([fullscreen]) #secondary.ytd-watch-flexy {
    margin-top: var(--subs-gap-theater);
    transition: margin-top 0.25s;
}
`

let canToggleSubsWithKeyboard = true;
const ccSizes = {
    0: 64,
    1: 80,
}

function addStyles(css, id) {
    const style = document.head.appendChild(document.createElement('style'))
    style.textContent = css;
    style.id = id
}

function displaceSubtitles(below = true) {
    // this elt gets special attribute by youtube when view mode changes,
    // so it also gets my marker class to apply my CSS
    const playerElt = document.querySelector(PLAYER_ELT_SELECTOR)
    if (below)
        playerElt.classList.add(USERJS_ELT_CLASS)
    else
        playerElt.classList.remove(USERJS_ELT_CLASS)
}

function onSubsClick() {
    displaceSubtitles(areSubsEnabled())
}

function getCaptionsButton() {
    return getVisibleElt(SUBS_BUTTON_SELECTOR)
}

function getVisibleElt(selector) {
    return Array.from(document.querySelectorAll(selector)).find(e => e.offsetParent !==null)
}

function isItVideoPage() {
    return window.location.search.includes('v=')
}

function areSubsAvailable() {
    const subsButton = getCaptionsButton()

    if (!subsButton) {
        console.debug(`Video ${window.location.href} has no subtitles button`);
        return false
    }

    // Video may have no subs at all, to catch that case
    // I can only check button's opacity
    const btnIcon = subsButton.querySelector('svg')
    if (parseFloat(btnIcon.getAttribute("fill-opacity") || 1) != 1)
        return false

    return true
}

function toggleSubtitlesKeyDown(e) {
    
    if (e.code === 'KeyC' || e.keyCode === 67)
    if (isItVideoPage() && areSubsAvailable() && canToggleSubsWithKeyboard) {
        displaceSubtitles(areSubsEnabled())
    }
}

function onFocusIn(e) {

    // disable captions toggling 
    // if user focused any input element (like search bar or comment textarea)
    if (e.target.tagName === 'INPUT' || 
    e.target.matches('div#contenteditable-root.style-scope.yt-formatted-string')) {
        canToggleSubsWithKeyboard = false
    } else {
        canToggleSubsWithKeyboard = true
    }
}

function onFocusOut(e) {

    // restoring captions toggling if user focused out
    // input elements
    if (e.target.tagName === 'INPUT' || 
    e.target.matches('div#contenteditable-root.style-scope.yt-formatted-string')) {
        canToggleSubsWithKeyboard = true
    }
}

function enchanceSubsButton() {
    if (isItVideoPage()) {

        if (!areSubsAvailable()) 
            return

        const subsButton = getCaptionsButton()

        let subsEnabled = areSubsEnabled()
        // sometimes I cannot rely on local storage setting
        // to get subtitles state but I still can get it
        // from the caption button ARIA attribute
        let subsButtonPressed = subsButton.getAttribute('aria-pressed') === 'true'
        displaceSubtitles(subsEnabled || subsButtonPressed)
        
        // forcing YT to enable subs
        if (subsEnabled && !subsButtonPressed) {
            subsButton.click()
        }

        updateGapSize()

        document.addEventListener('keydown', toggleSubtitlesKeyDown )
        subsButton.addEventListener('click', onSubsClick)
    }
}

function localStorageHook() {
    
    let original = Storage.prototype.setItem;
    Storage.prototype.setItem = function() {
        const event = new CustomEvent('storageSetItem', {
            detail: { 
                key: arguments[0], 
                value: arguments[1]
            },
        });
        original.apply(this, arguments);
        window.dispatchEvent(event);
    }
}

function getGapSize(f, initial) {
    // this formula is found in youtube js code
    return initial * (1 + 0.25 * Math.max(f || 0, 0));
}

function updateGapSize() {
    // NOTE CC visible size remains FIXED even if a user changes
    // zoom level in his browser!

    const raw = localStorage.getItem(KEY__PLAYER_CAPTION_DISPLAY_SETTINGS)
    let ccDisplaySettings;
    try {
        ccDisplaySettings = JSON.parse(JSON.parse(raw).data)
    } catch(e) {
        ccDisplaySettings = {}
    }
    const fontSizeIncrement = ccDisplaySettings.fontSizeIncrement || 0
    let newGap = getGapSize(fontSizeIncrement, SUBS_GAP);
    document.querySelector(PLAYER_ELT_SELECTOR).style.setProperty('--subs-gap', `${newGap}px`)
    newGap = getGapSize(fontSizeIncrement, SUBS_GAP_THEATER)
    document.querySelector(PLAYER_ELT_SELECTOR).style.setProperty('--subs-gap-theater', `${newGap}px`)
}

/** Checks if local storage has a setting for 
 * enabled/disabled subs.
 * 
 * **NOTE**: This setting might not be in local storage even
 * if a registered YT user enabled subtitles globally 
 * with YT settings. 
 * @returns {Bool} 
 */
function areSubsEnabled() {
    const raw = localStorage.getItem(KEY__PLAYER_STICKY_CAPTION)
    try {
        return JSON.parse(JSON.parse(raw).data)
    } catch(e) {
        return false
    }
}

function init() {
    addStyles(USERJS_STYLE_CONTENT, USERJS_STYLE_ID)

    document.addEventListener('focusin', onFocusIn)
    document.addEventListener('focusout', onFocusOut)

    // Hint about youtube-specific events was found there
    // https://stackoverflow.com/questions/34077641/how-to-detect-page-navigation-on-youtube-and-modify-its-appearance-seamlessly/34100952#34100952
    document.addEventListener('yt-navigate-finish', enchanceSubsButton)
    document.addEventListener('yt-page-data-updated', enchanceSubsButton)

    localStorageHook();

    window.addEventListener('storageSetItem', e => {
        const { key } = e.detail;

        // console.log(`YT local storage "${key}" was updated`)

        switch (key) {
            case KEY__PLAYER_CAPTION_DISPLAY_SETTINGS:
                
                updateGapSize()
                break;

            case KEY__PLAYER_STICKY_CAPTION:
    
                // if sticky subs are enabled - displace subs
                displaceSubtitles(areSubsEnabled())
                break;

            default:
                break;
        }

    })
}

init();