Greasy Fork is available in English.

YouTube Ultrawide

Crops the top and bottom to fit ultrawide

// ==UserScript==
// @name            YouTube Ultrawide
// @version         0.1
// @description     Crops the top and bottom to fit ultrawide
// @author          uxamend (modified by C4illin)
// @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*
// @icon            https://raw.githubusercontent.com/C4illin/Userscripts/master/Youtube-Ultrawide/icon.png
// @grant           none
// @run-at          document-idle
// @license         CC0-1.0
// @compatible      firefox version >=64 (older versions untested)
// @compatible      chrome version >=71 (older versions untested)
// @namespace https://greasyfork.org/users/217869
// ==/UserScript==

// 100% credit to uxamend: (I just changed a couple values to reverse everything)
// https://greasyfork.org/sv/scripts/375648-zoom-and-crop-youtube-videos-to-fill-screen-height

"use strict"

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

// For wide screens: Sets the widest aspect ratio that videos will ever be
// cropped to.
var max_cropped_aspect = 21/9 // Express as a fraction, e.g. 21/9, not 21:9.

// 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 = 0 // 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 = false

// 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 width"

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:
 *     max_cropped_aspect    minimum aspect ratio to crop videos down to
 */
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 < max_cropped_aspect) {
      debug_log("Video is wider than containing area and max_cropped_aspect. Setting zoom.")

      var vw = cw // vh = video height

      // Apply max_cropped_aspect constraint to video width
      if (max_cropped_aspect < containing_aspect) {
        vh = cw/max_cropped_aspect
        debug_log("max_cropped_aspect reached: " + max_cropped_aspect)
      } 

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

      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 + "." + "Aspect: " + containing_aspect)
      // 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("max_cropped_aspect: " + max_cropped_aspect + "." )
      debug_log("video_aspect: " + video_aspect + ".")  

      // 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:
 *     max_cropped_aspect    minimum aspect ratio to crop videos down to
 */
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:
 *     max_cropped_aspect    minimum aspect ratio to crop videos down to
 *     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 = "Normal (" + 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 = "Widescreen (" + 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()