您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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();