Playback Shortcuts

Adds playback shortcuts to video players. ('Ctrl + >'/'Ctrl + <' to change playback rate, 'Ctrl + .' to enter PiP)

  1. // ==UserScript==
  2. // @name Playback Shortcuts
  3. // @namespace endorh
  4. // @version 1.1
  5. // @description Adds playback shortcuts to video players. ('Ctrl + >'/'Ctrl + <' to change playback rate, 'Ctrl + .' to enter PiP)
  6. // @author endorh
  7. // @match https://*/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  9. // @license MIT
  10. // @grant GM_addStyle
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16.  
  17.  
  18. // == UserSettings ==
  19.  
  20. // Hotkeys
  21. let enterPiPHotkey = (e) => e.key == '.' && e.ctrlKey
  22. let decreasePlaybackRateHotkey = (e) => e.key == '<' && e.ctrlKey
  23. let increasePlaybackRateHotkey = (e) => e.key == '>' && e.ctrlKey
  24.  
  25. // Playback rate steps (must be sorted!)
  26. let playbackRates = [
  27. 0.01, 0.025, 0.05, 0.1, 0.15, 0.20,
  28. 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2,
  29. 2.5, 3, 3.5, 4, 5, 7.5, 10, 15, 20
  30. ] // Extra playback steps
  31. // let playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] // Classic YouTube rate steps
  32.  
  33. // Playback overlay fade timeout in ms
  34. let fadeTimeout = 350 // ms
  35.  
  36. // CSS style of the overlay. The overlay consists of three divs:
  37. // - outer container (sibling to the video element)
  38. // It's applied the `globalPlaybackRateOverlayFadeOut` class after `fadeTimeout` ms
  39. // have happened since the last playback rate change
  40. // - container (child to the outer container)
  41. // Has the .globalPlaybackRateOverlayContainer class
  42. // Its `position` should be `absolute`, as its actual position rectangle is updated
  43. // on every playback rate change to match that of the video.
  44. // Should be entirely transparent, not interactable and have a high z-index.
  45. // - overlay (child to the container)
  46. // Its content is set to `${video.playbackRate}x` after each playback rate change.
  47. // Can be centered with respect to its parent, which should match the dimensions of
  48. // the video.
  49. // Should be semitransparent to not disturb the video.
  50. GM_addStyle(`
  51. /* Container style */
  52. .globalPlaybackRateOverlayContainer {
  53. position: absolute;
  54. transition: opacity 0.05s;
  55. pointer-events: none;
  56. z-index: 2147483647;
  57. }
  58.  
  59. /* Container style after fadeTimeout */
  60. .globalPlaybackRateOverlayFadeOut {
  61. transition: opacity 0.35s;
  62. opacity: 0%;
  63. }
  64.  
  65. /* Overlay div style */
  66. .globalPlaybackRateOverlay {
  67. position: absolute;
  68. left: 50%;
  69. top: 50%;
  70. transform: translate(-50%, -50%);
  71.  
  72. padding: 0.5em;
  73. font-size: 24px;
  74. border-radius: 0.25em;
  75.  
  76. color: #FEFEFEFE !important;
  77. background-color: #000000A0 !important;
  78. }
  79. `)
  80.  
  81. // Set to true to enable console messages
  82. let debug = false
  83.  
  84. // Set to true to enable console warnings
  85. let warn = debug || true
  86.  
  87. // Set to false if a website is using modification observers near the video element
  88. // to detect added overlays, breaking the video.
  89. let useOverlay = true
  90.  
  91.  
  92.  
  93. // == Script ==
  94. // Object to preserve state across events
  95. let state = {};
  96.  
  97. // Add the keyboard hook
  98. document.addEventListener('keydown', function(e) {
  99. if (enterPiPHotkey(e)) {
  100. if (enterPiP()) {
  101. if (debug) console.info("Entered PiP")
  102. }
  103. } else if (decreasePlaybackRateHotkey(e)) {
  104. if (modifyPlaybackRate(false)) {
  105. if (debug) console.info("Decreased playback rate")
  106. e.preventDefault()
  107. }
  108. } else if (increasePlaybackRateHotkey(e)) {
  109. if (modifyPlaybackRate(true)) {
  110. if (debug) console.info("Increased playback rate")
  111. e.preventDefault()
  112. }
  113. }
  114. })
  115.  
  116. function findVideoElement() {
  117. // Find video element
  118. let videos = [...document.getElementsByTagName('video')]
  119. var video = null
  120. if (videos.length == 0) {
  121. if (debug) console.info("No video found.")
  122. return null
  123. } else if (videos.length == 1) {
  124. video = videos[0]
  125. } else {
  126. if (warn) console.warn("Multiple videos found, using only the first video found")
  127. if (debug) console.log(videos);
  128. video = videos[0]
  129. }
  130. return video
  131. }
  132.  
  133. // Enter PiP (Picture-in-Picture)
  134. function enterPiP() {
  135. let video = findVideoElement()
  136. if (video == null) return false
  137. let doc = video.ownerDocument
  138. // Toggle Picture-in-Picture
  139. if (doc.pictureInPictureElement != video) {
  140. if (doc.pictureInPictureElement) {
  141. doc.exitPictureInPicture()
  142. }
  143. video.requestPictureInPicture()
  144. } else doc.exitPictureInPicture()
  145. return true
  146. }
  147.  
  148. // Modify the playback
  149. function modifyPlaybackRate(faster) {
  150. let video = findVideoElement()
  151. if (video == null) return false
  152.  
  153. // Current playback rate
  154. let pr = video.playbackRate
  155.  
  156. // Find target playback (comparisons use a 1e-7 delta to avoid rounding nonsense)
  157. let target = faster? playbackRates.find(r => r > pr + 1e-7) : playbackRates.findLast(r => r < pr - 1e-7)
  158. if (debug) console.info("Changing playbackRate: " + pr + " -> " + target)
  159.  
  160. // Set playback rate
  161. video.playbackRate = target
  162. if (debug) console.log("Modified playbackRate: " + video.playbackRate)
  163.  
  164. // Check changed playback rate
  165. if (warn && video.playbackRate != target) {
  166. console.warn("Could not modify playbackRate!\nTarget: " + target + "\nActual: " + video.playbackRate)
  167. }
  168.  
  169. // Display overlay with the final playback rate
  170. if (useOverlay) updateOverlay(video, video.playbackRate);
  171. return true
  172. }
  173.  
  174. // Display an overlay with the updated playback rate
  175. function updateOverlay(v, rate) {
  176. // Reuse previous overlay
  177. var container = null
  178. if (state.overlay !== undefined) {
  179. if (state.timeout !== undefined) clearTimeout(state.timeout)
  180. container = state.overlay
  181. } else container = document.createElement('div')
  182.  
  183. // Inline positions and rate value
  184. let parent = v.parentElement
  185. let r = v.getBoundingClientRect()
  186. let p = parent.getBoundingClientRect()
  187. let html = `
  188. <div class="globalPlaybackRateOverlayContainer" style="
  189. left: ${r.left - p.left}px;
  190. right: ${r.right - p.left}px;
  191. top: ${r.top - p.top}px;
  192. bottom: ${r.bottom - p.top}px;
  193. width: ${r.width}px;
  194. height: ${r.height}px;
  195. ">
  196. <div class="globalPlaybackRateOverlay">
  197. ${rate}x
  198. </div>
  199. </div>
  200. `
  201. container.innerHTML = html
  202.  
  203. // Remove fade out
  204. container.classList.remove("globalPlaybackRateOverlayFadeOut")
  205.  
  206. // Add overlay
  207. if (state.overlay !== undefined && state.overlay.parentElement != v.parentElement) {
  208. state.overlay.parentElement.removeChild(state.overlay)
  209. state.overlay = undefined
  210. }
  211. if (state.overlay === undefined) {
  212. v.parentElement.appendChild(container)
  213. state.overlay = container
  214. }
  215.  
  216. // Set timeout for the fade out animation
  217. state.timeout = setTimeout(function() {
  218. container.classList.add("globalPlaybackRateOverlayFadeOut")
  219. state.timeout = undefined
  220. }, fadeTimeout);
  221. }
  222. })();