Zoom and crop YouTube videos to fill screen height

Removes letterboxing by cropping the left and right edges off of full-screen YouTube videos and zooming to fill the screen height.

// ==UserScript==
// @name            Zoom and crop YouTube videos to fill screen height
// @version         0.6.4
// @description     Removes letterboxing by cropping the left and right edges off of full-screen YouTube videos and zooming to fill the screen height.
// @author          uxamend
// @namespace       https://greasyfork.org/en/users/231373-uxamend
// @match           https://www.youtube.com/*
// @match           https://www.youtube-nocookie.com/embed/*
// @exclude-match   https://www.youtube.com/ad_frame*
// @exclude-match   https://www.youtube.com/ad_companion*
// @exclude-match   https://www.youtube.com/embed/
// @exclude-match   https://www.youtube.com/video_masthead*
// @grant           none
// @run-at          document-idle
// @license         CC0-1.0
// @compatible      firefox version >=64 (older versions untested)
// @compatible      chrome version >=71 (older versions untested)
// ==/UserScript==

// In @exclude-matches above, we want to exclude exactly
// https://www.youtube.com/embed/, but not https://www.youtube.com/embed/*
// /embed/ without a subdirectory appears in the background of normal YouTube,
// whereas /embed/* is for embedded videos on other sites.

"use strict";

// ==================================
//   User-defined global parameters
// ==================================

// For tall screens: Sets the narrowest aspect ratio that videos will ever be
// cropped to.
var min_cropped_aspect = 16/10; // Express as a fraction, e.g. 16/10, not 16:10.

// For very wide videos: The maximum proportion of video width to crop off
var max_crop_proportion = 1;

// Sets the aspect ratio of the player for using zoom outside of full-screen.
// This has no effect in full-screen mode.
// To use the screen aspect ratio as the player aspect ratio, set this to zero.
var player_aspect = 16/9; // Set to zero or a fraction, e.g. 16/9, not 16:9.

// Default zoom state outside full-screen (true = enabled, false = disabled)
var def_zoom_n = false;

// Default zoom state in full-screen (true = enabled, false = disabled)
var def_zoom_f = true;

// Sets which key will be used to enable and disable zoom
var zoom_shortcut_key = "z";

// ==================================


var debug_logging_on = false;
var debug_script_name = "Userscript: Zoom YouTube videos to fill screen height"

function debug_log(message) {
  if(debug_logging_on){
    console.log("[" + debug_script_name + "] " + message);
  }
}

/**
 * set_zoom
 * Zooms a specified video to fill specified containing area dimensions.
 * Parameters:
 *     video   video to zoom
 *     cw      containing area width
 *     ch      containing area height
 * Global parameters read:
 *     min_cropped_aspect    minimum aspect ratio to crop videos down to
 *     max_crop_proportion   maximum proportion of video width to crop away
 */
function set_zoom(video, cw, ch) {
  var vs = video.style;
  var video_aspect = video.videoWidth/video.videoHeight;
  var containing_aspect = cw/ch;

  // Don't zoom if the endscreen is showing
  if(!video.ended) {
    // Only zoom and crop videos that are wide enough to crop
    if(video_aspect > containing_aspect && video_aspect > min_cropped_aspect) {
      debug_log("Video is wider than containing area and min_cropped_aspect. Setting zoom.");

      var vh = ch; // vh = video height

      // Apply min_cropped_aspect constraint to video height
      if (min_cropped_aspect > containing_aspect) vh = cw/min_cropped_aspect;

      var vw = video_aspect * vh; // vw = video width (including cropped portion)

      // Apply max_crop_proportion constraint to video width
      if (cw/vw < 1-max_crop_proportion) vw = cw + vw * max_crop_proportion;

      var vt = (ch-vh)/2; // vt = top edge position of video
      var vl = (cw-vw)/2; // vl = left edge position of video

      debug_log("Containing area dimensions: " + cw + "x" + ch + ".");
      debug_log("Calculated new video element dimensions: " + vw + "x" + vh + ", origin at " + vl + ", " + vt + ".");
      debug_log("(Underlying video stream resolution: " + video.videoWidth + "x" + video.videoHeight + ".)");
      debug_log("containing_aspect: " + containing_aspect + ".");
      debug_log("min_cropped_aspect: " + min_cropped_aspect + "." );
      debug_log("video_aspect: " + video_aspect + ".");
      debug_log("max_crop_proportion: " + max_crop_proportion + ".");

      // This might appear to risk creating an endless loop via the mutation
      // observer. However, it doesn't. I'm guessing that changing the dimensions
      // doesn't constitute a mutation, but if it does it can result in at most one
      // superfluous execution of set_zoom(). If the first execution causes a
      // mutation by changing the video element's dimensions, then the second
      // execution, if it is surplus to requirements, should set them to the same
      // values, resulting in no mutation and no third execution (until genuinely
      // needed).
      vs.height = vh + "px";
      vs.width = vw + "px";
      vs.top = vt + "px";
      vs.left = vl + "px";

    } else {
      debug_log("Video is not wide enough to require zoom ("
                + video.videoWidth
                + "x"
                + video.videoHeight
                + "). Not setting zoom.");
      unzoom(video, cw, ch);
    }
  } else {
    debug_log("Video has ended. Not setting zoom. (Otherwise we mess with the endscreen.)");
  }
}

/**
 * unzoom
 * Undoes the visual effects of set_zoom.
 * Note that unzoom does not gurantee to return the video dimensions exactly to
 * their original values, but the visual appearance should be the same (or near
 * enough as makes no odds).
 * Parameters:
 *     video   video to unzoom
 *     cw      containing area width
 *     ch      containing area height
 */
function unzoom(video, cw, ch) {
  // It would be better to somehow trigger YouTube's standard video sizing, but
  // in the absence of a way to trigger that, we'll just do this.
  
  var vs = video.style;
  var video_aspect = video.videoWidth/video.videoHeight;
  var containing_aspect = cw/ch;
  
  // Don't unzoom if the endscreen is showing
  if(!video.ended) {
    debug_log("Unzooming video.");
    
    // Usually the player is sized to fit the video exactly in default view,
    // but not for narrow videos, which are pillarboxed with white bars. Rarely,
    // the player defaults to 16:9 for all videos, so that wide videos are
    // letterboxed with white bars.
    // 
    // In theater mode and full-screen mode, the player has a fixed aspect and
    // the video is letter- or pillarboxed with black bars if it doesn't fit
    // exactly.
    // 
    // To avoid black bars in default view, we must size the video to fill the
    // container in the video's longest dimension only. (Otherwise we could
    // just size it to fill the container in both dimensions.)
    var w, h, t, l;
    if(video_aspect == containing_aspect) {
      // video that fits the container exactly
      w = cw; h = ch; t = 0; l = 0;
    } else if(video_aspect > containing_aspect) {
      // letterboxed video
      w = cw; l = 0;
      h = cw / video_aspect;
      t = (ch - h) / 2;
    } else {
      // pillarboxed video
      h = ch; t = 0;
      w = ch * video_aspect;
      l = (cw - w) / 2;
    }
    
    vs.width = w + "px";
    vs.height = h + "px";
    vs.top = t + "px";
    vs.left = l + "px";
  } else {
    debug_log("Video has ended. Not unzooming. (Otherwise we mess with the endscreen.)");
  }
}

/**
 * in_theater_mode
 * Returns true if we're in Theater mode.
 */
function in_theater_mode() {
  return (document.getElementById("player-theater-container") &&
          document.getElementById("player-theater-container").childElementCount > 0 &&
          !document.fullscreenElement);
}

/**
 * set_player_aspect
 * Changes the aspect ratio of the video player element to the specified aspect
 * ratio, as interpreted by YouTube's default CSS.
 * Parameters:
 *     aspect            aspect ratio to use
 *     theater_default   if true, set theater mode to the default aspect ratio
 *                       instead of the specified aspect ratio
 */
function set_player_aspect(aspect, theater_default=false) {
  debug_log("Setting player aspect to " + aspect + ".");
  
  // We need to set overflow to hidden on the movie-player otherwise the video
  // overhangs in miniplayer mode. Get it by class name rather than id, for
  //  compatibility with embedded videos
  document.getElementsByClassName("html5-video-player")[0].style.setProperty("overflow", "hidden");
  
  // For embedded videos, we don't need to do anything.
  
  // For default view
  if(document.getElementsByTagName("ytd-watch-flexy")[0]) {
    var ytdwfs = document.getElementsByTagName("ytd-watch-flexy")[0].style;
    ytdwfs.setProperty("--ytd-watch-flexy-width-ratio", aspect);
    ytdwfs.setProperty("--ytd-watch-flexy-height-ratio", 1);
  }
  
  // For theater mode
  var ptc = document.getElementById("player-theater-container");
  
  if(in_theater_mode() && !theater_default) {
    debug_log("Setting theater mode height.");
    // 56px for masthead; --ytd-masthead-height is not always set, so can't use
    // that unfortunately
    ptc.style.setProperty("max-height", "calc(100vh - 56px)");
    ptc.style.setProperty("height", "calc((" + (1/aspect) + ") * 100vw)");
  } else {
    debug_log("Unsetting theater mode height.");
    if(ptc) {
      ptc.style.removeProperty("max-height");
      ptc.style.removeProperty("height");
    }
  }
}

/**
 * apply_player_aspect
 * To facilitate zoom and crop when the movie_player is not full-screen, this sets
 * the aspect ratio of the movie_player to follow the player_aspect setting.
 * Calling with the zoom parameter set to false returns the player to the YouTube
 * default of matching the video aspect ratio.
 * Parameters:
 *     zoom   if true, use player_aspect
 *            if false, use the actual video aspect (YouTube default)
 * Global parameters read:
 *     player_aspect   the aspect ratio to use, or zero (indicating to use the
 *                     screen aspect)
 */
function apply_player_aspect(zoom=true) {
  var video = document.getElementsByClassName("html5-main-video")[0];
  var video_aspect = video.videoWidth/video.videoHeight;
  
  if(zoom) {
    if(player_aspect == 0) {
      debug_log("Adjusting player aspect ratio to match screen.");
      var screen_aspect = window.screen.width/window.screen.height;
      if(video_aspect > screen_aspect) {
        set_player_aspect(screen_aspect);
      } else {
        debug_log("No need to change player aspect; video is not wide enough.");
      }
    } else {
      debug_log("Adjusting player aspect ratio to configured value.");
      if(video_aspect > player_aspect) {
        set_player_aspect(player_aspect); 
      } else {
        debug_log("No need to change player aspect; video is not wide enough.");
      }
    }
  } else {
    debug_log("Restoring player aspect ratio to match video.");
    set_player_aspect(video_aspect, true);
    // N.B. If video_aspect is narrow, the expected behaviour of set_player_aspect
    // is that YouTube's CSS may result in the video being pillarboxed, due to the
    // maximum height constraint.
  }
}

/**
 * set_zoom_to_window
 * Zooms a video to fill the window dimensions.
 * Parameters:
 *     video   the video to set zoom for
 *     zoom    if false, will unzoom instead of setting zoom
 * Global parameters read:
 *     min_cropped_aspect    minimum aspect ratio to crop videos down to
 *     max_crop_proportion   maximum proportion of video width to crop away
 */
function set_zoom_to_window(video, zoom=true) {
  if(zoom) {
    set_zoom(video,
           window.innerWidth,
           window.innerHeight);
  } else {
    unzoom(video,
           window.innerWidth,
           window.innerHeight);
  }
}

/**
 * set_zoom_to_movie_player
 * Zooms a video to fill its containing movie_player element in the DOM. When not in
 * full-screen mode, also changes the size of the movie_player to follow the
 * player_aspect setting (else there'll be no zoom and crop).
 * Parameters:
 *     video   the video to set zoom for
 *     zoom    if false, will unzoom instead of setting zoom
 * Global parameters read:
 *     min_cropped_aspect    minimum aspect ratio to crop videos down to
 *     max_crop_proportion   maximum proportion of video width to crop away
 *     player_aspect         aspect ratio setting for non-full-screen movie_player
 */
function set_zoom_to_movie_player(video, zoom=true) {
  if(!document.fullscreenElement) {
    // The movie_player is the grandparent node of the video element.
    // Open question: Is it more likely for the ID of the relevant element to
    // change (so that selecting it as the grandparent is the best strategy), or
    // for its position in the DOM tree to change (so that selecting it by ID is
    // the best strategy)?
    if(zoom) {
      apply_player_aspect(true);
      set_zoom(video,
               video.parentNode.parentNode.clientWidth,
               video.parentNode.parentNode.clientHeight);
    } else {
      unzoom(video,
             video.parentNode.parentNode.clientWidth,
             video.parentNode.parentNode.clientHeight);
    }
  } else {
    apply_player_aspect(false);
    
    // In full-screen mode, the movie-player is not necessarily the same size as
    // the screen, which can cause a slight offset. Use set_zoom_to_window instead
    // for this case.
    set_zoom_to_window(video, zoom);
  }
  zoom_button.update();
}

/**
 * mo_callback
 * Callback function for mutation observer, to re-apply zoom if the video element
 * mutates. E.g. when an ad starts or stops playing, or in other circumstances
 * when the video might change dimensions or become reset to its default,
 * letterboxed state.
 */
function mo_callback(mutation_list, observer) {
  mutation_list.forEach((mutation) => {
    if(mutation.type == "attributes"){
      
      // We have to check whether zoom "should" be on, because the
      // fullscreenchange event may not be fast enough, in which case we will
      // catch the mutations caused by exiting full-screen.
      if(zoom_should_be_on()) {
        debug_log("Video element mutated.");
        set_zoom_to_movie_player(mutation.target);
      } else {
        debug_log("Video element mutated but zoom should be off.");
        zoom_off();
      }
    }
  });
}

var mo = new MutationObserver(mo_callback);

function observe_video_mutations(video) {
  mo.observe(video, {"attributes" : true});
}

/**
 * zoom_on
 * Unconditionally apply zoom and keep it applied until zoom_off is called.
 */
function zoom_on() {
  debug_log("Turning zoom on.");
  var video = document.getElementsByClassName("html5-main-video")[0];
  set_zoom_to_movie_player(video);
  observe_video_mutations(video);
}

/**
 * zoom_off
 * Unconditionally stop applying zoom, until zoom_on is called.
 */
function zoom_off() {
  debug_log("Turning zoom off.");
  mo.disconnect();
  var video = document.getElementsByClassName("html5-main-video")[0];
  apply_player_aspect(false);
  set_zoom_to_movie_player(video, false);
}

// Manual zoom state outside full-screen
var man_enab_n = def_zoom_n;

// Manual zoom state in full-screen
var man_enab_f = def_zoom_f;

/**
 * zoom_should_be_on
 * Returns true if we're in a state where zoom is supposed to currently be
 * enabled, else false.
 */
function zoom_should_be_on() {
  return ((man_enab_n && !document.fullscreenElement)
       || (man_enab_f && document.fullscreenElement));
}

/**
 * zoom_on_or_off
 * Puts zoom into the correct on/off state, as per zoom_should_be_on.
 */
function zoom_on_or_off() {
  if(zoom_should_be_on()) {
    setTimeout(zoom_on, 200);
  } else {
    zoom_off();
  }
}

/**
 * toggle_manual_enab
 * Changes the manual override zoom state for the current display mode; either
 * full-screen or non-full-screen.
 */
function toggle_manual_enab() {
  debug_log("Toggling manual enable state.");
  if(document.fullscreenElement){
    man_enab_f = !man_enab_f;
    if(man_enab_f) debug_log("Set full-screen zoom enabled.");
    else debug_log("Set full-screen zoom disabled.");
  } else {
    man_enab_n = !man_enab_n;
    if(man_enab_n) debug_log("Set non-full-screen zoom enabled.");
    else debug_log("Set non-full-screen zoom disabled.");
  }
  zoom_on_or_off();
}

/**
 * handle_keydown
 * Event handler for any keydown events, to trigger appropriate actions.
 */
function handle_keydown(e) {
  debug_log('"' + e.key + '" key was pressed.');
  if(e.key == zoom_shortcut_key.toLowerCase()) toggle_manual_enab();
  if(e.key == zoom_shortcut_key.toUpperCase()) toggle_manual_enab();
  if(e.key == "i") {
    // Workaround for bug: exiting miniplayer directly into normal view does not
    // trigger reapplication of zoom. Pressing 'i' seems to be the only way to
    // trigger this bug, so detecting the pressing of 'i' seems like a good way
    // to fix it.
    zoom_on_or_off();
  }
}

/**
 * watch_for_fullscreen
 * Start watching for changes to the full-screen state, and make sure the correct
 * zoom state is applied at each transition of full-screen state.
 * N.B. There may be a slight delay in reaction to changes in full-screen state.
 */
function watch_for_fullscreen() {
  debug_log("Adding fullscreenchange event listener.");
  document.addEventListener(
    'fullscreenchange',
    function() {
      debug_log("Full-screen state changed.");
      zoom_on_or_off();
    }
  );
}

/**
 * watch_for_keypresses
 * Start watching for keydown events and handle them when they occur.
 */
function watch_for_keypresses() {
  debug_log("Adding keydown event listener.");
  document.addEventListener(
    'keydown',
    handle_keydown
  );
}

/**
 * create_zoom_button
 * Adds a zoom button to the YouTube player controls, which toggles manual override of
 * zoom state.
 * Returns:
 *     an object representing the button
 */
function create_zoom_button() {
  var right_controls;
  var size_button;
  var tooltip_showing = false;
  var button;
  var icon_path;

  /**
   * set_zoom_button_mode
   * Sets the zoom button to an appropriate mode for the current zoom state.
   */
  function set_zoom_button_mode() {
    var l;

    if(zoom_should_be_on()) {
      icon_path.setAttribute("d",
  "m 8,11 0,14 20,0 0,-14 -20,0 z m 2,4 16,0 0,6 -16,0 0,-6 z"
                       );
      l = "Letterbox (" + zoom_shortcut_key + ")"
    } else {
      icon_path.setAttribute("d",
  "m 4,11 0,14 3,0 0,-2 -1,0 0,-10 1,0 0,-2 -3,0 z\
   m 4,0 0,14 20,0 0,-14 -20,0 z\
   m 21,0 0,2 1,0 0,10 -1,0 0,2 3,0 0,-14 -3,0 z\
   m -19,2 16,0 0,10 -16,0 0,-10 z"
                       );
      l = "Zoom and crop (" + zoom_shortcut_key + ")"
    }

    button.setAttribute("aria-label", l);
    button.setAttribute("title", l);
  }
  
  /**
   * create_zoom_button_icon
   * Adds the icon to the zoom button during initial creation of the button.
   */
  function create_zoom_button_icon() {
    // Create icon SVG element
    var s = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    s.setAttribute("height", "100%");
    s.setAttribute("version", "1.1");
    s.setAttribute("viewBox", "0 0 36 36");
    s.setAttribute("width", "100%");

    var p_id = "zac-path-1";

    // Apply shadow
    var sh = document.createElementNS("http://www.w3.org/2000/svg", "use");
    sh.setAttribute("class", "ytp-svg-shadow");
    sh.setAttribute("href", "#" + p_id);

    // Create icon path
    var p = document.createElementNS("http://www.w3.org/2000/svg", "path");
    p.setAttribute("id", p_id);
    p.setAttribute("class", "ytp-svg-fill");

    // Append path and shadow to SVG
    s.appendChild(sh);
    s.appendChild(p);

    // Append icon to button
    button.appendChild(s);

    icon_path = p;
  }
  
  /**
   * show_zoom_button_tooltip
   * Shows the tooltip associated with the zoom button in a style mimicking that
   * of the other YouTube player buttons.
   * Parameters:
   *     show     if false, the tooltip will be hidden instead of shown
   */
  function show_zoom_button_tooltip(show=true) {
    // Position calculations
    var bbcr = button.getBoundingClientRect();
    var tt_horiz_cen = bbcr.left + bbcr.width/2; // tooltip horizontal centre

    var tt_top_offset = 57; // How far above the button should the tooltip be?

    // For some reason, the existing tooltips are at a different offset in full-screen.
    if(document.fullscreenElement) {
      tt_top_offset = 75;
    }

    var tt_top = bbcr.top + bbcr.height/2 - tt_top_offset; // tooltip top

    // YouTube has an existing tooltip DOM structure that it reuses for all of its
    // player tooltips, but it's easier and more reliable to just create our own,
    // using the same classes.

    // Try to get our existing tooltip from DOM from previous run
    var tt = document.getElementById("zac-tt");

    var tt_text;

    if(!tt) {
      // Create tool-tip DOM structure if not present.
      var mp = document.getElementsByClassName("html5-video-player")[0];
      var tt_text_wrapper = document.createElement("div");
      tt = document.createElement("div");
      tt_text = document.createElement("span");

      tt.setAttribute("class", "ytp-tooltip ytp-bottom");
      tt.setAttribute("id", "zac-tt");
      tt.style.setProperty("position", "fixed");

      tt_text_wrapper.setAttribute("class", "ytp-tooltip-text-wrapper");

      tt_text.setAttribute("class", "ytp-tooltip-text");
      tt_text.setAttribute("id", "zac-tt-text");

      tt.appendChild(tt_text_wrapper);
      tt_text_wrapper.appendChild(tt_text);
      mp.appendChild(tt);
    } else {
      // If DOM structure already present, get tooltip text.
      tt_text = document.getElementById("zac-tt-text");
    }

    if(show) { // show
      tt.style.setProperty("top", tt_top + "px");
      tt_text.innerHTML = button.getAttribute("aria-label");
      tt.style.removeProperty("display"); // show the tooltip

      // Calculate horizontal position. Tooltip must be showing before
      // its width can be queried.
      var tt_width = tt.getBoundingClientRect().width;
      tt.style.setProperty("left", tt_horiz_cen - tt_width / 2 + "px");
      debug_log("tt_width = " + tt_width);
      debug_log("tt_horiz_cen = " + tt_horiz_cen);
      debug_log("tt left position = " + (tt_horiz_cen - tt_width / 2));

      // Remove button title, else the browser may (will) display it as a
      // tooltip, in addition to ours.
      button.removeAttribute("title");
    } else { // hide
      tt.style.setProperty("display", "none");
      tt_text.innerHTML = "";
      button.setAttribute("title", button.getAttribute("aria-label"));
    }

    tooltip_showing = show;

    // All of that just for a tooltip that matches the others. And it's
    // still not perfect. Sheesh.
  }
  
  /**
   * update
   * Ensures the zoom button reflects the current state.
   */
  function update() { 
    set_zoom_button_mode();
    show_zoom_button_tooltip(tooltip_showing);
  }
  
  var button_object;
  
  function add_button() {
    right_controls = document.getElementsByClassName("ytp-right-controls")[0];
    size_button = document.getElementsByClassName("ytp-size-button") [0];
    
    if(right_controls && size_button) {
      debug_log("Adding zoom and crop toggle button.")
      
      // Remove existing button if present (sometimes it persists even after a page reload)
      var existing_button = document.getElementById("zac-zoom-button");
      if(existing_button) {
        debug_log("Destroying old zoom and crop toggle button.")
        right_controls.removeChild(existing_button);
      }
      
      // Create button
      button = document.createElement("button");
      button.setAttribute("class", "ytp-button");
      button.setAttribute("id", "zac-zoom-button");

      create_zoom_button_icon();
      set_zoom_button_mode();

      // Add button to controls
      right_controls.insertBefore(button, size_button);

      // Set event handlers
      button.addEventListener("click", toggle_manual_enab);
      button.addEventListener("mouseover", function(){show_zoom_button_tooltip();});
      button.addEventListener("mouseout", function(){show_zoom_button_tooltip(false);});
      button.addEventListener("focus", function(){show_zoom_button_tooltip();});
      button.addEventListener("blur", function(){show_zoom_button_tooltip(false);});

      button_object = { 
        update : update
      }

    } else {
      // Keep trying until we have somewhere to put the button.
      debug_log("Can't add zoom and crop toggle button yet. Retrying in 200ms.")
      setTimeout(add_button, 200);
    }
  }
  
  add_button();
  return button_object;
}


// Initialise
watch_for_fullscreen();
watch_for_keypresses();
var zoom_button = create_zoom_button();