俺的手机视频脚本

全屏横屏、快进快退、长按倍速,对各种视频网站的兼容性很强。仅适用于狐猴、kiwi等chromium内核的浏览器。使用前请先关闭同类横屏或手势脚本,以避免冲突。

目前为 2024-05-23 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name 俺的手机视频脚本
  3. // @description 全屏横屏、快进快退、长按倍速,对各种视频网站的兼容性很强。仅适用于狐猴、kiwi等chromium内核的浏览器。使用前请先关闭同类横屏或手势脚本,以避免冲突。
  4. // @version 1.6.24
  5. // @author shopkeeperV
  6. // @namespace https://gf.qytechs.cn/zh-CN/users/150069
  7. // @match *://*/*
  8. // @grant GM_setValue
  9. // @grant GM_getValue
  10. // @grant GM_addValueChangeListener
  11. // @grant GM_registerMenuCommand
  12. // @grant window.onurlchange
  13. // ==/UserScript==
  14. /*jshint esversion: 8*/
  15. (function () {
  16. 'use strict';
  17. if (navigator.userAgent.search("Android") < 0) {
  18. //return;
  19. }
  20. //放开iframe全屏
  21. let iframes = document.getElementsByTagName("iframe");
  22. for (let iframe of iframes) {
  23. iframe.allowFullscreen = true;
  24. }
  25. //部分网站阻止视频操作层触摸事件传播,需要指定监听目标,默认是document
  26. //注意,对少数iframe内视频,广告插件或使此脚本不起作用
  27. let listenTarget = document;
  28. //youtube使用无刷新网页,需要监听地址变化重新监听操控层
  29. if (window.location.host === "m.youtube.com") {
  30. let timer;
  31. let refresh = function () {
  32. console.log("俺的手机视频脚本:页面刷新...");
  33. //每到一个新页面应该清除定时器,以免上一个页面尚未清除又添加了新的
  34. if (timer) clearInterval(timer);
  35. //youtube视频在脚本执行时还没加载,需要个定时器循环获取状态
  36. if (window.location.href.search("watch") >= 0) {
  37. console.log("俺的手机视频脚本:已创建定时器。");
  38. timer = setInterval(() => {
  39. console.log("俺的手机视频脚本:正在获取视频...");
  40. //特定的视频操控层
  41. let videos = document.getElementsByTagName("video");
  42. let listenTargetArray = document.getElementsByClassName("player-controls-background");
  43. if (videos.length > 0) {
  44. let video = videos[0];
  45. //非静音播放中
  46. if (video.readyState > 1 && !video.paused && !video.muted) {
  47. //视频已加载
  48. listenTarget = listenTargetArray[0];
  49. console.log("俺的手机视频脚本:开始监听手势。");
  50. listen();
  51. clearInterval(timer);
  52. console.log("俺的手机视频脚本:清除定时器。");
  53. }
  54. }
  55. }, 500);
  56. }
  57. };
  58. refresh();
  59. window.addEventListener("urlchange", refresh);
  60. }
  61. //通用
  62. listen();
  63. if (GM_getValue("voiced") == null) {
  64. GM_setValue("voiced", true);
  65. }
  66. GM_registerMenuCommand("启用/关闭【触摸视频时取消静音】", () => {
  67. let voiced = GM_getValue("voiced");
  68. if (voiced) {
  69. if (confirm("目前【触摸视频时取消静音】已开启,是否要关闭?")) {
  70. GM_setValue("voiced", !voiced);
  71. }
  72. } else {
  73. if (confirm("目前【触摸视频时取消静音】已关闭,是否要开启?")) {
  74. GM_setValue("voiced", !voiced);
  75. }
  76. }
  77. });
  78.  
  79. function listen() {
  80. if (listenTarget.tagName/*监听的是元素*/) {
  81. //做个标记方便调试
  82. listenTarget.setAttribute("listen_mark", true);
  83. }
  84. //对视频的查找与控制都是在每次touchstart后重新执行的
  85. //虽然这样更消耗性能,但是对不同的网站兼容性更强
  86. listenTarget.addEventListener("touchstart", (e) => {
  87. //为了代码逻辑在普通视频与iframe内视频的通用性,分别使用了clientX和screenY
  88. let startX;
  89. let startY;
  90. let endX;
  91. let endY;
  92. //多根手指不做响应
  93. if (e.touches.length === 1) {
  94. //在全屏时,不对边缘5%的区域做响应
  95. let screenX = e.touches[0].screenX;
  96. let screenY = e.touches[0].screenY;
  97. if (document.fullscreenElement) {
  98. if (screenX < screen.width * 0.05 || screenX > screen.width * 0.95 ||
  99. screenY < screen.height * 0.05 || screenY > screen.height * 0.95)
  100. return;
  101. }
  102. //单指触摸,记录位置
  103. startX = Math.ceil(e.touches[0].clientX);
  104. startY = Math.ceil(screenY);
  105. endX = startX;
  106. endY = startY;
  107. } else return;
  108. let videoElement;
  109. //触摸的目标如果是视频或视频操控层,那他也是我们绑定手势的目标
  110. let target = e.target;
  111. //用于有操控层的网站,保存的是视频与操控层适当尺寸下的最大共同祖先节点,确认后需要在后代内搜索视频元素
  112. let biggestContainer;
  113. let targetWidth = target.clientWidth;
  114. let targetHeight = target.clientHeight;
  115. //所有大小合适的祖先节点最后一个为biggestContainer
  116. let suitParents = [];
  117. //用于判断是否含有包裹视频的a标签,需要禁止其被长按时呼出浏览器菜单
  118. let allParents = [];
  119. let temp = target;
  120. //用于抖音类网站,滚动高度超过阈值后,不再继续查找祖先
  121. let findAllSuitParent = false;
  122. //抖音类短视频网站,特点是视频操控层占据几乎整个屏幕
  123. let maybeTiktok = false;
  124. //用于短视频判断
  125. let scrollHeightOut = false;
  126. while (true) {
  127. temp = temp.parentElement;
  128. if (!temp/*或直接点击到html元素,他将没有父元素*/) {
  129. return;
  130. }
  131. //allParents全部保存,用于判断是否存在a标签
  132. allParents.push(temp);
  133. if (!findAllSuitParent &&
  134. temp.clientWidth > 0 &&
  135. temp.clientWidth < targetWidth * 1.2 &&
  136. temp.clientHeight > 0 &&
  137. temp.clientHeight < targetHeight * 1.2) {
  138. //用非全屏状态下scrollHeight来判断可以准确找到抖音类网站的合适视频容器
  139. if (document.fullscreenElement) {
  140. //全屏时视觉尺寸合适都可以用,youtube全屏就有滚动高度超出限制的元素
  141. //suitParents保存适合的尺寸的祖先节点
  142. suitParents.push(temp);
  143. } else {
  144. //非全屏时要判断一下滚动高度
  145. if (temp.scrollHeight < targetHeight * 1.2) {
  146. suitParents.push(temp);
  147. } else {
  148. findAllSuitParent = true;
  149. scrollHeightOut = true;
  150. }
  151. }
  152. }
  153. //循环结束条件
  154. if (temp.tagName === "BODY" ||
  155. temp.tagName === "HTML" ||
  156. !temp.parentElement) {
  157. //已找到所有符合条件的祖先节点,取最后一个
  158. if (suitParents.length > 0) {
  159. biggestContainer = suitParents[suitParents.length - 1];
  160. }
  161. //没有任何大小合适的祖先元素,且自身不是视频元素,那也肯定不是视频操控层
  162. else if (target.tagName !== "VIDEO") {
  163. return;
  164. }
  165. //gc
  166. suitParents = null;
  167. break;
  168. }
  169. }
  170. //当触摸的不是视频元素,可能是非视频相关组件,或视频的操控层
  171. if (target.tagName !== "VIDEO") {
  172. //尝试获取视频元素
  173. let videoArray = biggestContainer.getElementsByTagName("video");
  174. if (videoArray.length > 0) {
  175. videoElement = videoArray[0];
  176. //找到视频元素后,可以判断是否可能是短视频
  177. //非全屏状态下,非iframe内视频,若视频操作层或视频占据大半的屏幕,判断为短视频
  178. //tiktok没有视频控件,判断这个防止有页面的预览视频铺满了屏幕,这一项只能判断到没有框架的视频
  179. if (!document.fullscreenElement &&
  180. top === window &&
  181. !videoElement.controls &&
  182. scrollHeightOut &&
  183. target.clientHeight > window.innerHeight * 0.8) {
  184. maybeTiktok = true;
  185. }
  186. //如果是视频外很大的容器绝非我们想要的
  187. //操作层除了短视频没见过高度高视频这么多的,大概率不是视频操控层
  188. let _videoWidth = videoElement.clientWidth;
  189. let _videoHeight = videoElement.clientHeight;
  190. if (!maybeTiktok && targetHeight > _videoHeight * 1.5) {
  191. //不是合适的操作层
  192. return;
  193. }
  194. if (videoArray.length > 1) {
  195. console.log("触摸位置找到不止一个视频。");
  196. }
  197. } else {
  198. //非视频相关组件
  199. return;
  200. }
  201. }
  202. //触摸的是视频元素,则一切清晰明了
  203. else {
  204. videoElement = target;
  205. }
  206. //用于比较单击后,视频的播放状态,如果单击暂停,则恢复播放
  207. let playing = !videoElement.paused;
  208. //下面两个连通tiktok变量3个参数用于判断是否要执行touchmove事件处理器
  209. //小于30s当做预览视频,在网页上的视频列表可能存在,不要让他们影响网页滚动
  210. let sampleVideo = false;
  211. let videoReady = false;
  212. let videoReadyHandler = function () {
  213. videoReady = true;
  214. if (videoElement.duration < 30) {
  215. sampleVideo = true;
  216. }
  217. };
  218. if (videoElement.readyState > 0) {
  219. videoReadyHandler();
  220. } else {
  221. videoElement.addEventListener("loadedmetadata", videoReadyHandler, {once: true});
  222. }
  223. //一个合适尺寸的最近祖先元素用于显示手势信息与全屏按钮
  224. let componentContainer = findComponentContainer();
  225. //指示器元素
  226. let notice;
  227. //视频快进快退量
  228. let timeChange = 0;
  229. //1表示右滑快进,2表示左滑快退,方向一旦确认就无法更改
  230. let direction;
  231. //优化a标签导致的长按手势中断问题(许多网站的视频列表的预览视频都是由a标签包裹)
  232. makeTagAQuiet();
  233. //禁止长按视频呼出浏览器菜单,为长按倍速做准备(没有视频框架的视频需要)
  234. if (!videoElement.getAttribute("disable_contextmenu")/*只添加一次监听器*/) {
  235. videoElement.addEventListener("contextmenu", (e) => {
  236. e.preventDefault();
  237. });
  238. videoElement.setAttribute("disable_contextmenu", true);
  239. }
  240. //禁止图片长按呼出浏览器菜单和拖动(部分框架视频未播放时,触摸到的是预览图,抖音类播放时摸到的都是图片)
  241. if (target.tagName === "IMG") {
  242. target.draggable = false;
  243. if (!target.getAttribute("disable_contextmenu")) {
  244. target.addEventListener("contextmenu", (e) => {
  245. e.preventDefault();
  246. });
  247. target.setAttribute("disable_contextmenu", true);
  248. }
  249. }
  250. let haveControls = videoElement.controls;
  251. let longPress = false;
  252. //长按倍速定时器
  253. let rateTimer = setTimeout(() => {
  254. videoElement.playbackRate = 4;
  255. videoElement.controls = false;
  256. //禁止再快进快退
  257. target.removeEventListener("touchmove", touchmoveHandler);
  258. //显示notice
  259. notice.innerText = "x4";
  260. notice.style.display = "block";
  261. longPress = true;
  262. rateTimer = null;
  263. }, 800);
  264. //有些网站预览视频位置实际在屏幕之外,需要加上平移的数值
  265. let screenWidth = screen.width;
  266. let componentMoveLeft = componentContainer.offsetLeft;
  267. let moveNum = Math.floor(componentMoveLeft * 1.1 / screenWidth);
  268. //添加指示器元素
  269. if (componentContainer) {
  270. notice = document.createElement("div");
  271. let noticeWidth = 110;//未带单位,后面需要加单位
  272. let noticeHeight = 30;
  273. let noticeTop = Math.round(componentContainer.clientHeight / 6);
  274. let noticeLeft = Math.round(moveNum * screenWidth + componentContainer.clientWidth / 2 - noticeWidth / 2);
  275. notice.style.cssText = "position:absolute;display:none;z-index:99999;" +
  276. "text-align:center;opacity:0.5;background-color:black;color:white;" +
  277. "font:16px/1.8 sans-serif;letter-spacing:normal;border-radius:4px;";
  278. notice.style.width = noticeWidth + "px";
  279. notice.style.height = noticeHeight + "px";
  280. notice.style.left = noticeLeft + "px";
  281. notice.style.top = noticeTop + "px";
  282. componentContainer.appendChild(notice);
  283. } else {
  284. //怎么可能有视频没有div包着啊
  285. console.log("该视频没有可以用于给快进指示器定位的祖先元素。");
  286. }
  287. //滑动流畅的关键1,passive为false代表处理器内调用preventDefault()不会被浏览器拒绝
  288. //mdn:文档级节点 Window、Document 和 Document.body默认是true,其他节点默认是false
  289. target.addEventListener("touchmove", touchmoveHandler/*, {passive: false}*/);
  290. target.addEventListener("touchend", () => {
  291. setTimeout(touchendHandler, 0);
  292. }, {once: true});
  293.  
  294. function makeTagAQuiet() {
  295. for (let element of allParents) {
  296. if (element.tagName === "A" &&
  297. !element.getAttribute("disable_menu_and_drag")) {
  298. //禁止长按菜单
  299. element.addEventListener("contextmenu", (e) => {
  300. e.preventDefault();
  301. });
  302. //禁止长按拖动
  303. element.draggable = false;
  304. element.setAttribute("disable_menu_and_drag", true);
  305. //没有长按菜单,用target="_blank"属性来平替
  306. element.target = "_blank";
  307. //不可能a标签嵌套a标签吧
  308. break;
  309. }
  310. }
  311. allParents = null;
  312. }
  313.  
  314. function findComponentContainer() {
  315. let temp = videoElement;
  316. while (true) {
  317. //寻找最近的有长宽数值的祖先节点
  318. if (temp.parentElement.clientWidth > 0 &&
  319. temp.parentElement.clientHeight > 0) {
  320. return temp.parentElement;
  321. } else {
  322. temp = temp.parentElement;
  323. }
  324. }
  325. }
  326.  
  327. function getClearTimeChange(timeChange) {
  328. timeChange = Math.abs(timeChange);
  329. let minute = Math.floor(timeChange / 60);
  330. let second = timeChange % 60;
  331. return (minute === 0 ? "" : (minute + "min")) + second + "s";
  332. }
  333.  
  334. function touchmoveHandler(moveEvent) {
  335. //触摸屏幕后,0.8s内如果有移动,清除长按定时事件
  336. if (rateTimer) {
  337. clearTimeout(rateTimer);
  338. rateTimer = null;
  339. }
  340. if (maybeTiktok || sampleVideo || !videoReady) {
  341. return;
  342. }
  343. //滑动流畅的关键2
  344. moveEvent.preventDefault();
  345. if (moveEvent.touches.length === 1) {
  346. //仅支持单指触摸,记录位置
  347. let temp = Math.ceil(moveEvent.touches[0].clientX);
  348. //x轴没变化,y轴方向移动也会触发,要避免不必要的运算
  349. if (temp === endX) {
  350. return;
  351. } else {
  352. endX = temp;
  353. }
  354. endY = Math.ceil(moveEvent.touches[0].screenY);
  355. //console.log("移动到" + endX + "," + endY);
  356. }
  357. //由第一次移动确认手势方向,就不再变更
  358. //10个像素起
  359. if (endX > startX + 10) {
  360. //快进
  361. if (!direction) {
  362. //首次移动,记录方向
  363. direction = 1;
  364. }
  365. if (direction === 1) {
  366. //方向未变化
  367. timeChange = endX - startX - 10;
  368. } else {
  369. timeChange = 0;
  370. }
  371. } else if (endX < startX - 10) {
  372. //快退
  373. if (!direction) {
  374. //首次移动,记录方向
  375. direction = 2;
  376. }
  377. if (direction === 2) {
  378. //方向未变化
  379. timeChange = endX - startX + 10;
  380. } else {
  381. timeChange = 0;
  382. }
  383.  
  384. } else if (timeChange !== 0) {
  385. timeChange = 0;
  386. } else {
  387. return;
  388. }
  389. if (notice.style.display === "none" /*已经显示了就不管怎么滑动了*/ &&
  390. Math.abs(endY - startY) > Math.abs(endX - startX)) {
  391. //垂直滑动不显示
  392. timeChange = 0;
  393. return;
  394. }
  395. //未到阈值不显示
  396. if (direction) {
  397. notice.style.display = "block";
  398. notice.innerText = (direction === 1 ? ">>>" : "<<<") + getClearTimeChange(timeChange);
  399. }
  400. }
  401.  
  402. function touchendHandler() {
  403. if (GM_getValue("voiced")) {
  404. videoElement.muted = false;
  405. }
  406. //所有非短视频自带的全视频区域的单击暂停,给他重新播放,手机不适合单击暂停,需要暂停的使用暂停按钮即可
  407. //带延迟是为了让网页自带的js先执行,videoElement.paused的状态才会判断准确
  408. setTimeout(() => {
  409. if (playing && videoElement.paused && !maybeTiktok) {
  410. videoElement.play();
  411. }
  412. }, 200);
  413. //一般有chrome自带视频控件的就是没用框架的视频
  414. //需要替换全屏按钮,不然无法显示快进指示器
  415. //非长按后手指抬起时才添加全屏按钮
  416. if (!longPress && videoElement.controls && !document.fullscreenElement) {
  417. let myFullscreenBtn = document.getElementById("myFullscreenBtn");
  418. if (!myFullscreenBtn) {
  419. let btn = document.createElement("div");
  420. btn.style.cssText = "z-index:9999999;position:absolute;" +
  421. "display:block;width:50px;" +
  422. "background-color:black;color:white;opacity:0.5;" +
  423. "padding:5px 2px;font:16px/1.2 sans-serif;font-weight:bold;text-align:center;" +
  424. "box-sizing:border-box;border:2px solid white;white-space:normal;";
  425. btn.innerText = "点我全屏";
  426. btn.id = "myFullscreenBtn";
  427. let divHeight = 50;
  428. btn.style.height = divHeight + "px";
  429. btn.style.top = Math.round(componentContainer.clientHeight / 2 - divHeight / 2 - 10) + "px";
  430. btn.style.left = Math.round(moveNum * screenWidth + componentContainer.clientWidth * 5 / 7) + "px";
  431. componentContainer.append(btn);
  432. btn.addEventListener("touchstart", async function () {
  433. clean();
  434. await componentContainer.requestFullscreen();
  435. });
  436. //屏蔽原生全屏按钮
  437. videoElement.controlsList = ["nofullscreen"];
  438. setTimeout(clean, 2000);
  439.  
  440. function clean() {
  441. let myFullscreenBtn = document.getElementById("myFullscreenBtn");
  442. if (myFullscreenBtn) myFullscreenBtn.remove();
  443. }
  444. }
  445. }
  446. //滑动长按判断
  447. if (endX === startX) {
  448. //长按
  449. //console.log("长按");
  450. if (rateTimer) {
  451. //定时器也许已经执行,此时清除也没关系
  452. clearTimeout(rateTimer);
  453. }
  454. if (longPress) {
  455. //长按快进结束如果原本有控制器,则恢复
  456. videoElement.controls = haveControls;
  457. videoElement.playbackRate = 1;
  458. }
  459. } else {
  460. if (timeChange !== 0) {
  461. //快进
  462. videoElement.currentTime += timeChange;
  463. }
  464. //console.log("x轴移动" + (endX - startX));
  465. //console.log("y轴移动" + (endY - startY));
  466. }
  467. target.removeEventListener("touchmove", touchmoveHandler);
  468. if (notice) notice.remove();
  469. }
  470. });
  471. }
  472.  
  473. //全屏横屏模块
  474. //将浏览器锁定方向的方法改掉,防止网页自带的js执行,当此脚本执行时又把他改回来
  475. //这是因为遇到有网站锁定为any后,且后于此脚本执行,那么手机倒着拿就会直接退出全屏
  476. window.tempLock = screen.orientation.lock;
  477. let myLock = function () {
  478. console.log("网页自带js试图执行lock()")
  479. };
  480. screen.orientation.lock = myLock;
  481. //顶层窗口负责执行横屏,因为iframe可能开启了沙箱机制无法锁定方向并无法修改
  482. //使用油猴的变量监听,绕开iframe跨域限制
  483. if (top === window) {
  484. GM_setValue("doLock", false);
  485. GM_addValueChangeListener("doLock", async function (key, oldValue, newValue, remote) {
  486. if (document.fullscreenElement && newValue) {
  487. //恢复lock()
  488. screen.orientation.lock = window.tempLock;
  489. await screen.orientation.lock("landscape");
  490. //变向结束再次修改lock()
  491. screen.orientation.lock = myLock;
  492. GM_setValue("doLock", false);
  493. }
  494. });
  495. }
  496. //全屏后触发resize次数,如果有iframe,每个document可不是共用这个值
  497. let inTimes = 0;
  498. //利用window的resize事件监听全屏动作,监听document常用的fullscreenchange事件可能因为后代停止传播而捕获不到
  499. window.addEventListener("resize", () => {
  500. //resize事件或先于全屏事件触发,此时判断是否全屏将出错,所以得设置延迟
  501. setTimeout(fullscreenHandler, 500);
  502. });
  503.  
  504. function fullscreenHandler() {
  505. //获取全屏元素,查找视频,判断视频长宽比来锁定方向
  506. let _fullscreenElement = document.fullscreenElement;
  507. if (_fullscreenElement) {
  508. //如果全屏元素是iframe,说明不是视频所在的document执行到这,记录也没用
  509. if (_fullscreenElement.tagName === "IFRAME") {
  510. return;
  511. }
  512. //inTimes==1可代表全屏
  513. inTimes++;
  514. } else if (inTimes > 0) {
  515. //此代码块可代表退出全屏
  516. inTimes = 0;
  517. } else {
  518. //退出全屏时多余的触发或者是其他与全屏无关的元素触发resize
  519. return;
  520. }
  521. if (inTimes !== 1) {
  522. return;
  523. }
  524. let videoElement;
  525. if (_fullscreenElement.tagName !== "VIDEO") {
  526. //最大的全屏元素不是视频本身,需要寻找视频元素
  527. let videoArray = _fullscreenElement.getElementsByTagName("video");
  528. if (videoArray.length > 0) {
  529. videoElement = videoArray[0];
  530. if (videoArray.length > 1) {
  531. console.log("全屏元素内找到不止一个视频。");
  532. }
  533. }
  534. } else videoElement = _fullscreenElement;
  535. //也可能不是视频在全屏
  536. if (videoElement) {
  537. let changeHandler = function () {
  538. //高度小于宽度,需要转向,landscape会自动调用陀螺仪
  539. if (videoElement.videoHeight < videoElement.videoWidth) {
  540. //开启沙盒机制的iframe修改sandbox属性无效,需要顶层窗口调用方向锁定
  541. GM_setValue("doLock", true);
  542. }
  543. };
  544. //视频未加载,在加载后再判断需不需要转向
  545. if (videoElement.readyState < 1) {
  546. videoElement.addEventListener("loadedmetadata", changeHandler, {once: true});
  547. } else {
  548. changeHandler();
  549. }
  550. }
  551. }
  552. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址