Android Double Tap to Seek Video

Adds double-tap seeking to Firefox android or any other browser efficiently

  1. // ==UserScript==
  2. // @name Android Double Tap to Seek Video
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0
  5. // @description Adds double-tap seeking to Firefox android or any other browser efficiently
  6. // @author Faisal Bhuiyan
  7. // @match *://*/*
  8. // @run-at document-start
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant GM_registerMenuCommand
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function () {
  16. 'use strict';
  17.  
  18. let lastTap = 0;
  19. const DOUBLE_TAP_DELAY = 300;
  20. let overlayContainer = null;
  21. let isInFullscreen = false;
  22. let isPlaying = false;
  23. const CONTROLS_SAFE_ZONE = 80; // Height in pixels for controls area
  24. const SIDE_ZONE_WIDTH = 25; // Width percentage for side touch areas
  25. let toastTimeout = null;
  26. let lastTouchTime = 0;
  27. let lastTouchX = 0;
  28. let lastTouchY = 0;
  29.  
  30. // Get current domain
  31. function getCurrentDomain() {
  32. return window.location.hostname;
  33. }
  34.  
  35. // Check if current site is blacklisted
  36. function isBlacklisted() {
  37. const blacklist = GM_getValue('blacklistedSites', []);
  38. return blacklist.includes(getCurrentDomain());
  39. }
  40.  
  41. // Toggle blacklist for current site
  42. function toggleBlacklist() {
  43. const domain = getCurrentDomain();
  44. const blacklist = GM_getValue('blacklistedSites', []);
  45. const isCurrentlyBlacklisted = blacklist.includes(domain);
  46.  
  47. if (isCurrentlyBlacklisted) {
  48. const newBlacklist = blacklist.filter(site => site !== domain);
  49. GM_setValue('blacklistedSites', newBlacklist);
  50. showToast('Site removed from blacklist');
  51. } else {
  52. blacklist.push(domain);
  53. GM_setValue('blacklistedSites', blacklist);
  54. showToast('Site added to blacklist');
  55. removeExistingOverlays();
  56. }
  57. }
  58.  
  59. // Register menu command
  60. GM_registerMenuCommand('Toggle Blacklist for Current Site', toggleBlacklist);
  61.  
  62. function showToast(message) {
  63. let toast = document.getElementById('video-seeker-toast');
  64. if (!toast) {
  65. toast = document.createElement('div');
  66. toast.id = 'video-seeker-toast';
  67. toast.style.cssText = `
  68. position: fixed !important;
  69. top: 50% !important;
  70. left: 50% !important;
  71. transform: translate(-50%, -50%) !important;
  72. background: rgba(0, 0, 0, 0.7) !important;
  73. color: white !important;
  74. padding: 10px 20px !important;
  75. border-radius: 5px !important;
  76. z-index: 2147483647 !important;
  77. pointer-events: none !important;
  78. transition: opacity 0.3s !important;
  79. font-family: Arial, sans-serif !important;
  80. `;
  81. document.body.appendChild(toast);
  82. }
  83.  
  84. toast.textContent = message;
  85. toast.style.opacity = '1';
  86.  
  87. if (toastTimeout) clearTimeout(toastTimeout);
  88. toastTimeout = setTimeout(() => {
  89. toast.style.opacity = '0';
  90. }, 2000);
  91. }
  92.  
  93. function removeExistingOverlays() {
  94. const existingOverlays = document.querySelectorAll('#video-overlay-container');
  95. existingOverlays.forEach(overlay => overlay.remove());
  96. overlayContainer = null;
  97. }
  98.  
  99. function createOverlayContainer() {
  100. removeExistingOverlays();
  101.  
  102. const container = document.createElement('div');
  103. container.id = 'video-overlay-container';
  104.  
  105. container.style.cssText = `
  106. position: absolute !important;
  107. top: 0 !important;
  108. left: 0 !important;
  109. right: 0 !important;
  110. bottom: ${CONTROLS_SAFE_ZONE}px !important;
  111. width: 100% !important;
  112. height: calc(100% - ${CONTROLS_SAFE_ZONE}px) !important;
  113. pointer-events: auto !important;
  114. z-index: 2147483647 !important;
  115. background: transparent !important;
  116. touch-action: manipulation !important;
  117. display: ${isPlaying ? 'block' : 'none'} !important;
  118. `;
  119.  
  120. const touchAreas = ['left', 'right'].map((position) => {
  121. const area = document.createElement('div');
  122. area.id = `touch-area-${position}`;
  123.  
  124. area.style.cssText = `
  125. position: absolute !important;
  126. ${position}: 0 !important;
  127. top: 0 !important;
  128. width: ${SIDE_ZONE_WIDTH}% !important;
  129. height: 100% !important;
  130. pointer-events: auto !important;
  131. background: rgba(255, 255, 255, 0.01) !important;
  132. z-index: 2147483647 !important;
  133. touch-action: manipulation !important;
  134. `;
  135.  
  136. function handleTouch(e) {
  137. try {
  138. if (!isPlaying || !isInFullscreen || isBlacklisted()) return;
  139.  
  140. const currentTime = new Date().getTime();
  141. const touch = e.touches && e.touches[0];
  142.  
  143. if (!touch && e.type === 'touchstart') return;
  144.  
  145. if (e.type === 'touchstart') {
  146. const tapLength = currentTime - lastTap;
  147. const touchX = touch.clientX;
  148. const touchY = touch.clientY;
  149.  
  150. // Check if this is a double tap (time and position)
  151. const isDoubleTap = tapLength < DOUBLE_TAP_DELAY &&
  152. Math.abs(touchX - lastTouchX) < 30 &&
  153. Math.abs(touchY - lastTouchY) < 30;
  154.  
  155. if (isDoubleTap) {
  156. const video = document.querySelector('video');
  157. if (video) {
  158. const seekAmount = position === 'right' ? 10 : -10;
  159.  
  160. if (video.player && typeof video.player.currentTime === 'function') {
  161. video.player.currentTime(video.player.currentTime() + seekAmount);
  162. } else {
  163. video.currentTime += seekAmount;
  164. }
  165.  
  166. showToast(`${seekAmount > 0 ? '+' : ''}${seekAmount} seconds`);
  167. }
  168. e.preventDefault();
  169. e.stopPropagation();
  170. }
  171.  
  172. // Update last touch info
  173. lastTap = currentTime;
  174. lastTouchX = touchX;
  175. lastTouchY = touchY;
  176. }
  177. } catch (err) {
  178. console.log('Touch handler error:', err);
  179. }
  180. }
  181.  
  182. ['touchstart', 'touchend'].forEach(eventType => {
  183. area.addEventListener(eventType, handleTouch, {
  184. passive: false,
  185. capture: true
  186. });
  187. });
  188.  
  189. return area;
  190. });
  191.  
  192. touchAreas.forEach(area => container.appendChild(area));
  193. return container;
  194. }
  195.  
  196. function updateOverlayVisibility() {
  197. if (overlayContainer) {
  198. overlayContainer.style.display = (isInFullscreen && isPlaying) ? 'block' : 'none';
  199. }
  200. }
  201.  
  202. function attachOverlay() {
  203. const fullscreenElement =
  204. document.fullscreenElement ||
  205. document.webkitFullscreenElement ||
  206. document.querySelector('.video-js.vjs-fullscreen') ||
  207. document.querySelector('video');
  208.  
  209. if (fullscreenElement && isInFullscreen) {
  210. overlayContainer = createOverlayContainer();
  211.  
  212. const container =
  213. fullscreenElement.querySelector('.vjs-tech-container') ||
  214. fullscreenElement.querySelector('.video-js') ||
  215. fullscreenElement;
  216.  
  217. if (container) {
  218. container.appendChild(overlayContainer);
  219. updateOverlayVisibility();
  220. }
  221. }
  222. }
  223.  
  224. function handleFullscreenChange() {
  225. const isNowFullscreen = !!(
  226. document.fullscreenElement ||
  227. document.webkitFullscreenElement ||
  228. document.querySelector('.video-js.vjs-fullscreen')
  229. );
  230.  
  231. if (isNowFullscreen && !isInFullscreen) {
  232. isInFullscreen = true;
  233. setTimeout(attachOverlay, 100);
  234. showToast('Double-tap controls enabled');
  235. } else if (!isNowFullscreen && isInFullscreen) {
  236. isInFullscreen = false;
  237. removeExistingOverlays();
  238. showToast('Double-tap controls disabled');
  239. }
  240. }
  241.  
  242. function handlePlayPause(video) {
  243. isPlaying = !(video.paused || video.ended || video.seeking || video.readyState < 3);
  244. updateOverlayVisibility();
  245. }
  246.  
  247. // Cleanup function for removing events
  248. function cleanup() {
  249. removeExistingOverlays();
  250. isInFullscreen = false;
  251. isPlaying = false;
  252. }
  253.  
  254. // Listen for fullscreen changes
  255. document.addEventListener('fullscreenchange', handleFullscreenChange, true);
  256. document.addEventListener('webkitfullscreenchange', handleFullscreenChange, true);
  257.  
  258. // Listen for page visibility changes
  259. document.addEventListener('visibilitychange', () => {
  260. if (document.hidden) {
  261. cleanup();
  262. }
  263. });
  264.  
  265. // Watch for video elements
  266. const observer = new MutationObserver((mutations, obs) => {
  267. const video = document.querySelector('video');
  268. if (video) {
  269. // Listen for all playback state changes
  270. ['play', 'pause', 'seeking', 'seeked', 'waiting', 'playing'].forEach(eventType => {
  271. video.addEventListener(eventType, () => handlePlayPause(video));
  272. });
  273.  
  274. video.addEventListener('webkitbeginfullscreen', () => {
  275. isInFullscreen = true;
  276. setTimeout(attachOverlay, 100);
  277. });
  278.  
  279. video.addEventListener('webkitendfullscreen', () => {
  280. cleanup();
  281. });
  282.  
  283. // Monitor video.js fullscreen class changes
  284. const videoJs = document.querySelector('.video-js');
  285. if (videoJs) {
  286. const classObserver = new MutationObserver((mutations) => {
  287. mutations.forEach((mutation) => {
  288. if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
  289. const isNowFullscreen = videoJs.classList.contains('vjs-fullscreen');
  290. if (isNowFullscreen !== isInFullscreen) {
  291. handleFullscreenChange();
  292. }
  293. }
  294. });
  295. });
  296.  
  297. classObserver.observe(videoJs, {
  298. attributes: true,
  299. attributeFilter: ['class']
  300. });
  301. }
  302.  
  303. // Initial state check
  304. handlePlayPause(video);
  305.  
  306. obs.disconnect();
  307. }
  308. });
  309.  
  310. observer.observe(document.body, {
  311. childList: true,
  312. subtree: true
  313. });
  314. })();