Better Keyboard Shortcuts for SharePoint

Simpler keyboard shorcuts with visual indicators for Microsoft SharePoint videos

  1. // ==UserScript==
  2. // @name Better Keyboard Shortcuts for SharePoint
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.3.0
  5. // @description Simpler keyboard shorcuts with visual indicators for Microsoft SharePoint videos
  6. // @author kazcfz
  7. // @include https://*.sharepoint.com/*
  8. // @icon https://res-1.cdn.office.net/shellux/stream_24x.12dba766a9c30382b781c971070dc87c.svg
  9. // @grant none
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. /* eslint curly: "off" */
  14.  
  15. (function () {
  16. 'use strict';
  17.  
  18. // Wait until .oneplayer-root exists in the DOM, then run callback with it
  19. function waitForOnePlayerRoot(callback) {
  20. const root = document.querySelector('.oneplayer-root') || document.querySelector('.OnePlayer-container');
  21. if (root) {
  22. callback(root);
  23. return;
  24. }
  25. const docObserver = new MutationObserver((mutations, obs) => {
  26. const el = document.querySelector('.oneplayer-root') || document.querySelector('.OnePlayer-container');
  27. if (el) {
  28. obs.disconnect();
  29. callback(el);
  30. }
  31. });
  32. docObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
  33. }
  34.  
  35. waitForOnePlayerRoot(videoRoot => {
  36. if (getComputedStyle(videoRoot).position === 'static')
  37. videoRoot.style.position = 'relative';
  38.  
  39. // Overlay to indicate Skip
  40. const skipOverlay = document.createElement('div');
  41. skipOverlay.style.position = 'absolute';
  42. skipOverlay.style.top = '50%';
  43. skipOverlay.style.transform = 'translateY(-50%)';
  44. skipOverlay.style.padding = '15px 15px';
  45. skipOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.9)';
  46. skipOverlay.style.borderRadius = '50%';
  47. skipOverlay.style.pointerEvents = 'none';
  48. skipOverlay.style.opacity = '0';
  49. skipOverlay.style.transition = 'opacity 0.1s ease';
  50. skipOverlay.style.zIndex = '9999';
  51. skipOverlay.style.userSelect = 'none';
  52. skipOverlay.style.display = 'flex';
  53. skipOverlay.style.flexDirection = 'column';
  54. skipOverlay.style.alignItems = 'center';
  55. skipOverlay.style.justifyContent = 'center';
  56. skipOverlay.style.gap = '0.2em';
  57. skipOverlay.style.minWidth = '75px';
  58. skipOverlay.style.minHeight = '75px';
  59. skipOverlay.style.textAlign = 'center';
  60. videoRoot.appendChild(skipOverlay);
  61.  
  62. // Overlay to indicate Volume
  63. const topMiddleOverlay = document.createElement('div');
  64. topMiddleOverlay.style.position = 'absolute';
  65. topMiddleOverlay.style.top = '10%';
  66. topMiddleOverlay.style.left = '0';
  67. topMiddleOverlay.style.right = '0';
  68. topMiddleOverlay.style.margin = 'auto';
  69. topMiddleOverlay.style.padding = '7px 11px';
  70. topMiddleOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
  71. topMiddleOverlay.style.borderRadius = '5px';
  72. topMiddleOverlay.style.pointerEvents = 'none';
  73. topMiddleOverlay.style.opacity = '0';
  74. topMiddleOverlay.style.transition = 'opacity 0.015s ease';
  75. topMiddleOverlay.style.zIndex = '9999';
  76. topMiddleOverlay.style.userSelect = 'none';
  77. topMiddleOverlay.style.display = 'flex';
  78. topMiddleOverlay.style.flexDirection = 'column';
  79. topMiddleOverlay.style.alignItems = 'center';
  80. topMiddleOverlay.style.justifyContent = 'center';
  81. topMiddleOverlay.style.gap = '0.2em';
  82. topMiddleOverlay.style.width = '50px';
  83. topMiddleOverlay.style.minHeight = '30px';
  84. topMiddleOverlay.style.textAlign = 'center';
  85. videoRoot.appendChild(topMiddleOverlay);
  86.  
  87. // Overlay to indicate Play/Pause
  88. const centerOverlay = document.createElement('div');
  89. centerOverlay.style.position = 'absolute';
  90. centerOverlay.style.top = '50%';
  91. centerOverlay.style.left = '50%';
  92. centerOverlay.style.right = 'auto';
  93. centerOverlay.style.margin = '0';
  94. centerOverlay.style.transform = 'translate(-50%, -50%)';
  95. centerOverlay.style.padding = '15px';
  96. centerOverlay.style.fontVariantEmoji = 'text';
  97. centerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
  98. centerOverlay.style.borderRadius = '50%';
  99. centerOverlay.style.pointerEvents = 'none';
  100. centerOverlay.style.opacity = '0';
  101. centerOverlay.style.zIndex = '9999';
  102. centerOverlay.style.userSelect = 'none';
  103. centerOverlay.style.display = 'flex';
  104. centerOverlay.style.flexDirection = 'column';
  105. centerOverlay.style.alignItems = 'center';
  106. centerOverlay.style.justifyContent = 'center';
  107. centerOverlay.style.textAlign = 'center';
  108. centerOverlay.style.color = 'rgba(255,255,255,0.85)';
  109. videoRoot.appendChild(centerOverlay);
  110.  
  111. const centerSymbol = document.createElement('span');
  112. centerSymbol.style.opacity = '1';
  113. centerOverlay.appendChild(centerSymbol);
  114.  
  115. // Overlay text for current volume
  116. const topMiddleText = document.createElement('div');
  117. topMiddleText.style.fontSize = '17px';
  118. topMiddleText.style.fontWeight = '500';
  119. topMiddleText.style.color = 'rgba(255,255,255,0.85)';
  120. topMiddleText.style.userSelect = 'none';
  121. topMiddleOverlay.appendChild(topMiddleText);
  122.  
  123. // Overlay container for left/right triangles
  124. const trianglesContainer = document.createElement('div');
  125. trianglesContainer.style.display = 'flex';
  126. trianglesContainer.style.gap = '10px';
  127. skipOverlay.appendChild(trianglesContainer);
  128.  
  129. // Add triangles into its container
  130. const triangles = [];
  131. for (let i = 0; i < 3; i++) {
  132. const tri = document.createElement('span');
  133. tri.textContent = '▶';
  134. tri.style.fontSize = '13px';
  135. tri.style.fontWeight = 'bold';
  136. trianglesContainer.appendChild(tri);
  137. triangles.push(tri);
  138. }
  139.  
  140. // Overlay text for seconds skipped
  141. const secondsText = document.createElement('div');
  142. secondsText.style.fontSize = '14px';
  143. secondsText.style.fontWeight = 'normal';
  144. secondsText.style.color = 'rgba(255,255,255,0.85)';
  145. secondsText.style.userSelect = 'none';
  146. skipOverlay.appendChild(secondsText);
  147.  
  148. let hideTimeoutSkipOverlay;
  149. let hideTimeoutTopMiddleOverlay;
  150. let hideTimeoutCenterOverlay;
  151. let animTimeouts = [];
  152.  
  153. // Displays overlay triangle animation and seconds skipped
  154. const secondsToSkip = 5;
  155. function showAnimatedTriangles(side) {
  156. // Clear animation timers
  157. clearTimeout(hideTimeoutSkipOverlay);
  158. animTimeouts.forEach(t => clearTimeout(t));
  159. animTimeouts = [];
  160.  
  161. const isLeft = side === 'left';
  162. const char = isLeft ? '◀' : '▶';
  163. const order = isLeft ? [2, 1, 0] : [0, 1, 2];
  164.  
  165. triangles.forEach(t => {
  166. t.textContent = char;
  167. t.style.transition = 'none';
  168. t.style.color = 'rgba(255,255,255,0.3)';
  169. });
  170.  
  171. // Force style flush to apply the color immediately before re-enabling transitions
  172. void skipOverlay.offsetHeight;
  173. triangles.forEach(t => { t.style.transition = 'color 0.3s ease'; });
  174.  
  175. secondsText.textContent = `${secondsToSkip} seconds`;
  176.  
  177. skipOverlay.style.opacity = '1';
  178. skipOverlay.style.left = isLeft ? '10%' : 'auto';
  179. skipOverlay.style.right = isLeft ? 'auto' : '10%';
  180. skipOverlay.style.textAlign = 'center';
  181.  
  182. triangles[order[0]].style.color = 'rgba(255,255,255,0.75)';
  183.  
  184. const interval = 200;
  185.  
  186. for (let step = 2; step <= 3; step++) {
  187. animTimeouts.push(setTimeout(() => {
  188. if (step === 2) {
  189. triangles[order[0]].style.color = 'rgba(255,255,255,0.5)';
  190. triangles[order[1]].style.color = 'rgba(255,255,255,0.75)';
  191. }
  192. else if (step === 3) {
  193. triangles[order[0]].style.color = 'rgba(255,255,255,0.3)';
  194. triangles[order[1]].style.color = 'rgba(255,255,255,0.5)';
  195. triangles[order[2]].style.color = 'rgba(255,255,255,0.75)';
  196. }
  197. }, (step - 1) * interval));
  198. }
  199.  
  200. hideTimeoutSkipOverlay = setTimeout(() => { skipOverlay.style.opacity = '0'; }, 3.3 * interval);
  201. }
  202.  
  203. // Checks that the video player is focused
  204. function isInOnePlayerRoot(element) {
  205. while (element) {
  206. if (element === videoRoot)
  207. return true;
  208. element = element.parentElement;
  209. }
  210. return false;
  211. }
  212.  
  213. let video = null;
  214.  
  215. function setupVideo(v) {
  216. if (!v || video === v)
  217. return;
  218. video = v;
  219. }
  220.  
  221. // Initial attempt to find video
  222. setupVideo(videoRoot.querySelector('video'));
  223.  
  224. // Observe videoRoot subtree for added/removed video
  225. const observer = new MutationObserver(mutations => {
  226. for (const mutation of mutations)
  227. for (const node of mutation.addedNodes)
  228. if (node.nodeType === 1)
  229. if (node.tagName === 'VIDEO')
  230. setupVideo(node);
  231. else {
  232. const v = node.querySelector && node.querySelector('video');
  233. if (v)
  234. setupVideo(v);
  235. }
  236. });
  237.  
  238. observer.observe(videoRoot, { childList: true, subtree: true });
  239.  
  240.  
  241. function triggerCenterSymbol(symbol) {
  242. clearTimeout(hideTimeoutCenterOverlay);
  243.  
  244. centerOverlay.style.width = '75px';
  245. centerOverlay.style.height = '75px';
  246. centerOverlay.style.transition = 'opacity 0.1s ease';
  247. centerOverlay.style.opacity = '1';
  248.  
  249. hideTimeoutCenterOverlay = setTimeout(() => {
  250. centerOverlay.style.transition = 'width 0.75s ease, height 0.75s ease, font-size 0.75s ease, opacity 0.75s ease';
  251. centerOverlay.style.width = `${parseFloat(getComputedStyle(centerOverlay).width) + 25}px`;
  252. centerOverlay.style.height = `${parseFloat(getComputedStyle(centerOverlay).height) + 25}px`;
  253. centerOverlay.style.fontSize = `${parseFloat(getComputedStyle(centerOverlay).fontSize) + 22.5}px`;
  254. centerOverlay.style.opacity = '0';
  255. }, 50);
  256.  
  257. centerSymbol.textContent = symbol;
  258. if (symbol === '🔊' || symbol === '🔇') {
  259. centerSymbol.style.marginBottom = '9px';
  260. centerSymbol.style.marginLeft = '5px';
  261. centerOverlay.style.fontSize = '60px';
  262. } else if (symbol === '🔉') {
  263. centerSymbol.style.marginBottom = '9px';
  264. centerSymbol.style.marginLeft = '0px';
  265. centerOverlay.style.fontSize = '60px';
  266. } else if (symbol === '▶') {
  267. centerSymbol.style.marginBottom = '7px';
  268. centerSymbol.style.marginLeft = '12px';
  269. centerOverlay.style.fontSize = '50px';
  270. } else if (symbol === '⏸') {
  271. centerSymbol.style.marginBottom = '14px';
  272. centerSymbol.style.marginLeft = '4px';
  273. centerOverlay.style.fontSize = '55px';
  274. }
  275. }
  276.  
  277.  
  278. // Math time: MS Stream/SharePoint handles volume steps exponentially (cubically) rather than linearly
  279. // The sequence is (steps of 0.1)^3, counting down from 1 to 0:
  280. // volume (base 𝑛) = (1 − 0.1 × 𝑛)^3, where 𝑛 = 0, 1, 2, ..., 10.
  281. // Alternatively, could just keep it to linear volume steps
  282. let currentVolumeStepCount = 0;
  283. function triggerTopMiddleText(action) {
  284. if (action == 'volume') {
  285. const volumePercent = video.volume * 100;
  286. if (volumePercent >= 1 || video.volume == 0)
  287. topMiddleText.textContent = `${Math.round(volumePercent)}%`;
  288. else
  289. topMiddleText.textContent = `${volumePercent.toFixed(1)}%`;
  290.  
  291. } else if (action == 'speed') {
  292. const speed = video.playbackRate;
  293. topMiddleText.textContent = (speed % 1 === 0) ? `${speed}x` : `${speed.toFixed(2)}x`;
  294. if (topMiddleText.textContent.endsWith('0x'))
  295. topMiddleText.textContent = `${speed.toFixed(1)}x`;
  296. }
  297.  
  298. topMiddleOverlay.style.opacity = '1';
  299. clearTimeout(hideTimeoutTopMiddleOverlay);
  300. hideTimeoutTopMiddleOverlay = setTimeout(() => { topMiddleOverlay.style.opacity = '0'; }, 500);
  301. }
  302.  
  303. document.addEventListener('keydown', e => {
  304. if (!video)
  305. return;
  306. if (e.altKey || e.ctrlKey || e.metaKey)
  307. return;
  308. if (document.activeElement && !isInOnePlayerRoot(document.activeElement))
  309. return;
  310.  
  311. e.preventDefault();
  312.  
  313. // Home to jump to start
  314. if (e.code === 'Home')
  315. video.currentTime = 0;
  316.  
  317. // End to jump to end
  318. else if (e.code === 'End')
  319. video.currentTime = video.duration;
  320.  
  321. // Period to skip to next frame
  322. else if (e.code === 'Period' && !e.shiftKey)
  323. video.currentTime = Math.min(video.duration, video.currentTime + (1/30));
  324.  
  325. // Comma to skip to previous frame
  326. else if (e.code === 'Comma' && !e.shiftKey)
  327. video.currentTime = Math.max(0, video.currentTime - (1/30));
  328.  
  329. // > to speed up
  330. else if (e.key === '>') {
  331. document.querySelector('[aria-description*="Alt + X"]').click();
  332. const items = [...document.querySelectorAll('[role="menuitemradio"]')];
  333. const currentIndex = items.findIndex(item => item.getAttribute('aria-checked') === 'true');
  334.  
  335. if (currentIndex > 0)
  336. items[currentIndex - 1].click();
  337. else
  338. document.querySelector('[aria-description*="Alt + X"]').click();
  339. triggerTopMiddleText('speed');
  340. }
  341.  
  342. // < to slow down
  343. else if (e.key === '<') {
  344. document.querySelector('[aria-description*="Alt + Z"]').click();
  345. const items = [...document.querySelectorAll('[role="menuitemradio"]')];
  346. const currentIndex = items.findIndex(item => item.getAttribute('aria-checked') === 'true');
  347.  
  348. if (currentIndex < items.length - 1)
  349. items[currentIndex + 1].click();
  350. else
  351. document.querySelector('[aria-description*="Alt + Z"]').click();
  352. triggerTopMiddleText('speed');
  353. }
  354.  
  355. // 0–9 to skip to 0%–90% of video
  356. else if (/^Digit[0-9]$/.test(e.code))
  357. video.currentTime = (video.duration * (parseInt(e.code.replace('Digit', ''), 10))) / 10;
  358.  
  359. // → to skip forward
  360. else if (e.code === 'ArrowRight') {
  361. video.currentTime = Math.min(video.duration, video.currentTime + secondsToSkip);
  362. showAnimatedTriangles('right');
  363.  
  364. // ← to skip backward
  365. } else if (e.code === 'ArrowLeft') {
  366. video.currentTime = Math.max(0, video.currentTime - secondsToSkip);
  367. showAnimatedTriangles('left');
  368.  
  369. // ↑ to increase volume
  370. } else if (e.code === 'ArrowUp') {
  371. if (currentVolumeStepCount > 0) {
  372. currentVolumeStepCount--;
  373. video.volume = Math.pow(1 - 0.1 * currentVolumeStepCount, 3);
  374. //video.volume = Math.max(0, Math.min(1, Math.round((video.volume + 0.1) * 100) / 100));
  375. }
  376. triggerTopMiddleText('volume');
  377. triggerCenterSymbol('🔊');
  378.  
  379. // ↓ to decrease volume
  380. } else if (e.code === 'ArrowDown') {
  381. if (currentVolumeStepCount < 10) {
  382. currentVolumeStepCount++;
  383. video.volume = Math.pow(1 - 0.1 * currentVolumeStepCount, 3);
  384. //video.volume = Math.max(0, Math.min(1, Math.round((video.volume - 0.1) * 100) / 100));
  385. }
  386. triggerTopMiddleText('volume');
  387.  
  388. if (video.volume == 0)
  389. triggerCenterSymbol('🔇');
  390. else
  391. triggerCenterSymbol('🔉');
  392.  
  393. // / to go to search box
  394. } else if (e.key === '/') {
  395. const searchInput = document.querySelector('input[role="combobox"][type="search"][placeholder="Search"]');
  396. searchInput.focus();
  397. searchInput.select();
  398. return;
  399.  
  400. // Trigger SharePoint's keyboard shortcuts / advanced features
  401. } else {
  402. // Adapted from [Sharepoint Keyboard Shortcuts] by [CyrilSLi], MIT License
  403. // https://greatest.deepsurf.us/en/scripts/524190-sharepoint-keyboard-shortcuts
  404. const keys = {
  405. Space: document.querySelector('[aria-description*="Alt + K"]'),
  406. KeyK: document.querySelector('[aria-description*="Alt + K"]'),
  407. KeyJ: document.querySelector('[aria-description*="Alt + J"]'),
  408. KeyL: document.querySelector('[aria-description*="Alt + L"]'),
  409. KeyF: document.querySelector('[aria-description*="Alt + Enter"]'),
  410. KeyM: document.querySelector('[aria-description*="Alt + M"]'),
  411. KeyC: document.querySelector('[aria-description*="Alt + C"]'),
  412. KeyA: document.querySelector('[aria-description*="Alt + A"]'),
  413. }
  414.  
  415. if (keys.hasOwnProperty(e.code))
  416. keys[e.code].click();
  417.  
  418.  
  419. if (e.code === 'Space' || e.code === 'KeyK') {
  420. if (video.paused)
  421. triggerCenterSymbol('⏸');
  422. else
  423. triggerCenterSymbol('▶');
  424.  
  425. } else if (e.code === 'KeyC') {
  426. const items = [...document.querySelectorAll('[role="menuitemradio"]')];
  427. const menuitem = document.querySelector('button[role="menuitem"][aria-label="Captions"]');
  428. const currentIndex = items.findIndex(item => item.getAttribute('aria-checked') === 'true');
  429.  
  430. if (currentIndex >= items.length - 1) {
  431. items[0].click();
  432. menuitem.setAttribute('aria-checked', 'false');
  433.  
  434. } else if (currentIndex < items.length - 1) {
  435. items[currentIndex + 1].click();
  436. menuitem.setAttribute('aria-checked', 'true');
  437. }
  438.  
  439. } else if (e.code === 'KeyM')
  440. if (video.muted)
  441. triggerCenterSymbol('🔊');
  442. else
  443. triggerCenterSymbol('🔇');
  444. }
  445.  
  446. document.querySelector(".fluent-critical-ui-container").focus();
  447. });
  448.  
  449. });
  450. })();