U-NEXT Skip Intro

Add missing skip intro/credit to U-NEXT player

  1. // ==UserScript==
  2. // @name U-NEXT Skip Intro
  3. // @name:zh-CN U-NEXT 跳过片头
  4. // @name:ja U-NEXT イントロスキップ
  5. // @namespace http://tampermonkey.net/
  6. // @match https://*.unext.jp/*
  7. // @run-at document-start
  8. // @grant unsafeWindow
  9. // @version 1.2
  10. // @author DiruSec
  11. // @license MIT
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=unext.jp
  13. // @description Add missing skip intro/credit to U-NEXT player
  14. // @description:zh-CN 给 U-NEXT 添加跳过片头/演职人员表的功能
  15. // @description:ja U-NEXT に「イントロ/クレジットをスキップ」機能を追加
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20. // define default variables
  21. let introObject = {
  22. startDuration: null,
  23. endDuration: null
  24. }
  25. let creditObject = {
  26. startDuration: null,
  27. endDuration: null
  28. }
  29. let moviePartsPositionList = []
  30. let episodeDuration = null
  31. let lastPlayTimeThrottle = null
  32. let playerPanelNode = null
  33. let hideSkipButtonWithPanel = false
  34. let moviePartsObjectInitialized = false
  35. let nextEpisodeObject = {
  36. titleCode: null,
  37. episodeCode: null,
  38. displayNo: null,
  39. episodeName: null,
  40. thumbnail: null,
  41. getPlayUrl() { return this.titleCode && this.episodeCode ? `https://video.unext.jp/play/${this.titleCode}/${this.episodeCode}` : null},
  42. getDisplayTitle() {return `${this.displayNo}\n${this.episodeName}`},
  43. }
  44.  
  45. function initializeGlobalVar() {
  46. introObject = {
  47. startDuration: null,
  48. endDuration: null
  49. }
  50. creditObject = {
  51. startDuration: null,
  52. endDuration: null
  53. }
  54. moviePartsPositionList = []
  55. episodeDuration = null
  56. lastPlayTimeThrottle = null
  57. playerPanelNode = null
  58. hideSkipButtonWithPanel = false
  59. moviePartsObjectInitialized = false
  60. nextEpisodeObject = {
  61. titleCode: null,
  62. episodeCode: null,
  63. displayNo: null,
  64. episodeName: null,
  65. thumbnail: null,
  66. getPlayUrl() { return this.titleCode && this.episodeCode ? `https://video.unext.jp/play/${this.titleCode}/${this.episodeCode}` : null},
  67. getDisplayTitle() {return `${this.displayNo}\n${this.episodeName}`},
  68. }
  69. }
  70.  
  71. function listenReactUrlChange() {
  72. // Save references to the original methods
  73. const originalPushState = history.pushState;
  74. const originalReplaceState = history.replaceState;
  75.  
  76. // Utility function to handle URL changes
  77. function onUrlChange() {
  78. console.log('React Router URL changed:', window.location.href);
  79.  
  80. // You can trigger a custom event or callback here
  81. const urlChangeEvent = new Event('reactRouterUrlChange');
  82. window.dispatchEvent(urlChangeEvent);
  83. }
  84.  
  85. // Override pushState
  86. history.pushState = function(...args) {
  87. originalPushState.apply(this, args);
  88. onUrlChange(); // Trigger the function on URL change
  89. };
  90.  
  91. // Override replaceState
  92. history.replaceState = function(...args) {
  93. originalReplaceState.apply(this, args);
  94. onUrlChange(); // Trigger the function on URL change
  95. };
  96. }
  97.  
  98. function preProcessRequest(requestOptions) {
  99. // condition checks
  100. if (!(requestOptions?.method === 'POST' && requestOptions?.headers['content-type'] === 'application/json' && requestOptions.body)) {
  101. return requestOptions;
  102. }
  103.  
  104. let requestBody;
  105. try {
  106. requestBody = JSON.parse(requestOptions.body);
  107. } catch (e) {
  108. console.error('[U-NEXT Skip Intro] invaild graphql request body found');
  109. return requestOptions;
  110. }
  111.  
  112. return requestOptions
  113.  
  114. }
  115.  
  116. function replaceGraphql(requestBody) {
  117. // replaces graphql to add intro/credit parts query
  118. const searchString = 'commodityCode\n movieAudioList {\n audioType\n __typename\n }\n ';
  119. if (!requestBody.query || !requestBody.query.includes(searchString)) {
  120. return requestBody;
  121. }
  122.  
  123. const replaceString = `${searchString}moviePartsPositionList {\n hasRemainingPart\n to\n from\n __typename\n }\n `;
  124. requestBody.query = requestBody.query.replace(searchString, replaceString);
  125. return requestBody
  126. }
  127.  
  128. async function handleGetNextEpisode(response) {
  129. try {
  130. const jsonData = await response.json();
  131. const data = jsonData.data?.webfront_postPlay;
  132.  
  133. if (!data || !data.nextEpisode) {
  134. console.warn('[U-NEXT Skip Intro] No next episode information found.');
  135. return null;
  136. }
  137.  
  138. const { titleCode, episodeCode, displayNo, episodeName, thumbnail } = data.nextEpisode;
  139.  
  140. return {
  141. titleCode,
  142. episodeCode,
  143. displayNo,
  144. episodeName,
  145. thumbnail: thumbnail.standard,
  146. };
  147. } catch (e) {
  148. console.error('[U-NEXT Skip Intro] Error parsing response:', e);
  149. return null;
  150. }
  151. }
  152.  
  153.  
  154. async function handleGetSkipDuration(response) {
  155. try {
  156. const jsonData = await response.json();
  157. const data = jsonData.data?.webfront_playlistUrl?.urlInfo && jsonData.data?.webfront_playlistUrl?.urlInfo[0];
  158.  
  159. if (!data || !data.moviePartsPositionList) {
  160. console.warn('[U-NEXT Skip Intro] No moviePartsPositionList information found.');
  161. return null;
  162. }
  163.  
  164. return data.moviePartsPositionList || [];
  165. } catch (e) {
  166. console.error('[U-NEXT Skip Intro] Error parsing response:', e);
  167. return [];
  168. }
  169. }
  170.  
  171. function handleParseSkipDuration() {
  172. console.log('moviePartsPositionList', moviePartsPositionList)
  173. if (moviePartsPositionList.length === 0) return;
  174.  
  175. // If there's only one part, compare 'from' with video duration/2
  176. if (moviePartsPositionList.length === 1) {
  177. const part = moviePartsPositionList[0];
  178. part.startDuration = Number(part.fromSeconds);
  179. part.endDuration = Number(part.endSeconds);
  180. part.duration = part.endDuration - part.startDuration;
  181.  
  182. if (part.type === 'OPENING') {
  183. introObject.startDuration = part.startDuration
  184. introObject.endDuration = part.endDuration
  185. part.label = 'Intro';
  186. } else {
  187. creditObject.startDuration = part.startDuration
  188. creditObject.endDuration = part.endDuration
  189. part.label = 'Credits';
  190.  
  191. part.hasRemainingPart === false && (creditObject.hasRemainingPart = false);
  192. }
  193. } else {
  194. // Logic for more than one part
  195. let introPart = moviePartsPositionList[0];
  196. let creditsPart = moviePartsPositionList[0];
  197.  
  198. moviePartsPositionList.forEach(part => {
  199. part.startDuration = Number(part.fromSeconds);
  200. part.endDuration = Number(part.endSeconds);
  201. part.duration = part.endDuration - part.startDuration;
  202.  
  203. // Find the earliest 'from' value for the intro
  204. if (part.startDuration < introPart.startDuration) {
  205. introPart = part;
  206. }
  207.  
  208. // Find the latest 'to' value for the credits
  209. if (part.endDuration > creditsPart.endDuration) {
  210. creditsPart = part;
  211. }
  212. });
  213.  
  214. introObject.startDuration = introPart.startDuration
  215. creditObject.startDuration = creditsPart.startDuration
  216. introObject.endDuration = introPart.endDuration
  217. creditObject.endDuration = creditsPart.endDuration
  218. creditObject.hasRemainingPart = creditsPart.hasRemainingPart
  219. // Assign labels
  220. introPart.label = 'Intro';
  221. creditsPart.label = 'Credits';
  222. }
  223. }
  224.  
  225. // Save the original fetch function
  226. const originalFetch = window.fetch;
  227.  
  228. // Override the fetch function
  229. const newFetch = async function (...args) {
  230. const url = args[0];
  231.  
  232. // Check if the URL matches the pattern
  233. const regex = /^https:\/\/cc\.unext\.jp\/\?/;
  234. const getPlaylistUrlStr = 'operationName=cosmo_getPlaylistUrl';
  235. const getPostPlayStr = 'operationName=cosmo_getPostPlay';
  236.  
  237. if (regex.test(url)) {
  238.  
  239. //let requestOptions = args[1];
  240. //args[1] = preProcessRequest(requestOptions)
  241.  
  242. // need to get something from response
  243. const response = await originalFetch(...args);
  244. const responseClone = response.clone()
  245.  
  246. try {
  247. //const requestBody = JSON.parse(requestOptions.body);
  248.  
  249. if (url.indexOf(getPlaylistUrlStr) !== -1) {
  250. let skipDuration = await handleGetSkipDuration(responseClone);
  251.  
  252. moviePartsPositionList = skipDuration
  253. moviePartsObjectInitialized = true
  254. } else if (url.indexOf(getPostPlayStr) !== -1) {
  255. let nextEpisode = await handleGetNextEpisode(responseClone);
  256. nextEpisode && (
  257. nextEpisodeObject.titleCode = nextEpisode.titleCode,
  258. nextEpisodeObject.episodeCode = nextEpisode.episodeCode,
  259. nextEpisodeObject.displayNo = nextEpisode.displayNo,
  260. nextEpisodeObject.episodeName = nextEpisode.episodeName,
  261. nextEpisodeObject.thumbnail = nextEpisode.thumbnail.standard
  262. )
  263. }
  264. } catch (e) {
  265. console.error('[U-NEXT Skip Intro] Error handling operationName:', e);
  266. }
  267.  
  268. // Return original Response object with no modification
  269. return response;
  270. }
  271.  
  272. // If the URL doesn't match, return the original fetch call
  273. return originalFetch(...args);
  274. };
  275.  
  276. Object.defineProperty(unsafeWindow, 'fetch', { value: newFetch, enumerable: false, writable: true });
  277.  
  278. // Function to create a button dynamically
  279. function createSkipButton(text, onClick) {
  280. const isPanelDisplayed = window.getComputedStyle(document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement, null).getPropertyValue('opacity') === '1'
  281. const button = document.createElement('button');
  282. button.id = 'introskip-btn-skip';
  283. button.innerText = text;
  284. button.style.position = 'absolute';
  285. button.style.bottom = isPanelDisplayed? '9.6rem': '3rem';
  286. button.style.right = '2rem';
  287. button.style.zIndex = '1000';
  288. button.addEventListener('click', onClick);
  289. createButtonStyle();
  290. return button;
  291. }
  292.  
  293. function createButtonStyle() {
  294. const style = document.createElement('style');
  295. style.innerHTML = `
  296. #introskip-btn-skip {
  297. background-color: #0F0F0FFF;
  298. color: #EEE;
  299. border: solid;
  300. border-color: #666;
  301. border-width: .1rem;
  302. border-radius: .2rem;
  303. cursor: pointer;
  304. padding: 1rem 2rem;
  305. opacity: 1;
  306. transition: all 0.2s ease;
  307. }
  308.  
  309. #introskip-btn-skip:hover {
  310. background-color: #0F0F0F99;
  311. transform: scale(1.05);
  312. }
  313.  
  314. #introskip-btn-skip.hide {
  315. opacity: 0;
  316. display: none;
  317. }
  318. `
  319. document.head.appendChild(style)
  320. }
  321.  
  322. function removeButtonStyle() {
  323. const styleSheets = document.head.querySelectorAll('style');
  324.  
  325. styleSheets.forEach(styleSheet => {
  326. if (styleSheet.innerHTML.includes('#introskip-btn-skip')) {
  327. styleSheet.remove();
  328. }
  329. });
  330. }
  331.  
  332. function setHideSkipButtonWithPanel() {
  333. hideSkipButtonWithPanel = true;
  334. playerPanelNode = document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement;
  335. let isDisplayed = window.getComputedStyle(playerPanelNode, null).getPropertyValue('opacity') === '1'
  336. document.querySelector('#introskip-btn-skip').className = hideSkipButtonWithPanel&&!isDisplayed?'hide':''
  337. }
  338.  
  339. // Function to add event listeners to the video
  340. function addSkipButtonsToVideo(video) {
  341. let skipIntroButton = null;
  342. let skipCreditsButton = null;
  343.  
  344. const callback = (mutationsList, observer) => {
  345. mutationsList.forEach((mutationObj) => {
  346. if (mutationObj.attributeName === 'class') {
  347. // for mutationsObserver, when opacity starts change, value will be the last moment before changes.
  348. let isDisplayed = window.getComputedStyle(playerPanelNode, null).getPropertyValue('opacity') === '0'
  349. let skipBtnDom = document.querySelector('#introskip-btn-skip')
  350. skipBtnDom && (skipBtnDom.className = hideSkipButtonWithPanel&&!isDisplayed?'hide':'')
  351. skipBtnDom && (skipBtnDom.style.bottom = isDisplayed?'9.6rem':'3rem')
  352. }
  353. })
  354. };
  355.  
  356. const observer = new MutationObserver(callback);
  357.  
  358. const config = { attributes: true, childList: false, subtree: false };
  359.  
  360. const skipIntroPress = event => {
  361. event.code === 'KeyS' && (video.currentTime = introObject.endDuration);
  362. }
  363. const skipCreditPress = event => {
  364. event.code === 'KeyS' && (video.currentTime = creditObject.endDuration);
  365. }
  366. const nextEpisodePress = event => {
  367. event.code === 'KeyS' && (window.location.href = nextEpisodeObject.getPlayUrl());
  368. }
  369. // Get the episode duration
  370. video.ondurationchange = function () {
  371. episodeDuration = video.duration;
  372. console.log(`Episode Duration: ${episodeDuration}`);
  373. };
  374.  
  375. // Listen to ontimeupdate event
  376. video.ontimeupdate = function () {
  377. const currentTime = video.currentTime;
  378.  
  379. // Skip Intro Button
  380. if (currentTime >= introObject.startDuration && currentTime <= introObject.endDuration) {
  381. if (introObject.endDuration - currentTime >= 5 && !lastPlayTimeThrottle) {
  382. lastPlayTimeThrottle = setTimeout(setHideSkipButtonWithPanel, 5000)
  383. }
  384. if (!skipIntroButton) {
  385. playerPanelNode = document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement;
  386.  
  387. skipIntroButton = createSkipButton('SKIP INTRO', ()=> {
  388. video.currentTime = introObject.endDuration;
  389. });
  390. window.addEventListener('keyup', skipIntroPress)
  391.  
  392. document.querySelector('#videoFullScreenWrapper').appendChild(skipIntroButton);
  393.  
  394. if (playerPanelNode) {
  395. observer.observe(playerPanelNode, config);
  396. } else {
  397. console.error("Target node not found.");
  398. }
  399. }
  400. } else if (skipIntroButton) {
  401. try {
  402. document.querySelector('#videoFullScreenWrapper').removeChild(skipIntroButton);
  403. } catch (e) {
  404. console.error('[U-NEXT Skip Intro] Cannot remove skip button. Page content maybe changed?', e);
  405. }
  406. observer.disconnect()
  407. window.removeEventListener('keyup', skipIntroPress)
  408. removeButtonStyle();
  409. clearTimeout(lastPlayTimeThrottle);
  410. lastPlayTimeThrottle = null;
  411. skipIntroButton = null;
  412. }
  413.  
  414. // Skip Credits or Next Episode Button
  415. if (currentTime >= creditObject.startDuration && currentTime <= creditObject.endDuration) {
  416. const timeDifference = episodeDuration - creditObject.endDuration;
  417. playerPanelNode = document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement;
  418.  
  419. if (creditObject.endDuration - currentTime >= 5 && !lastPlayTimeThrottle) {
  420. lastPlayTimeThrottle = setTimeout(setHideSkipButtonWithPanel, 5000)
  421. }
  422.  
  423. // Show "Next Episode" if the time difference is <= 10 seconds
  424.  
  425. if (creditObject.hasRemainingPart === false) {
  426. if (!skipCreditsButton || skipCreditsButton.innerText !== 'NEXT EPISODE') {
  427. if (skipCreditsButton) document.querySelector('#videoFullScreenWrapper').removeChild(skipCreditsButton);
  428. skipCreditsButton = createSkipButton('NEXT EPISODE', () => {
  429. window.location.href = nextEpisodeObject.getPlayUrl();
  430. });
  431. document.querySelector('#videoFullScreenWrapper').appendChild(skipCreditsButton);
  432. window.addEventListener('keyup', nextEpisodePress)
  433.  
  434. if (playerPanelNode) {
  435. observer.observe(playerPanelNode, config);
  436. } else {
  437. console.error("Target node not found.");
  438. }
  439. }
  440. }
  441. // Otherwise, show "Skip Credits"
  442. else {
  443. if (!skipCreditsButton || skipCreditsButton.innerText !== 'SKIP CREDITS') {
  444. if (skipCreditsButton) document.querySelector('#videoFullScreenWrapper').removeChild(skipCreditsButton);
  445. skipCreditsButton = createSkipButton('SKIP CREDITS', () => {
  446. video.currentTime = creditObject.endDuration;
  447. });
  448. document.querySelector('#videoFullScreenWrapper').appendChild(skipCreditsButton);
  449. window.addEventListener('keyup', skipCreditPress)
  450.  
  451. if (playerPanelNode) {
  452. observer.observe(playerPanelNode, config);
  453. } else {
  454. console.error("Target node not found.");
  455. }
  456. }
  457. }
  458. } else if (skipCreditsButton) {
  459. try {
  460. document.querySelector('#videoFullScreenWrapper').removeChild(skipCreditsButton);
  461. } catch (e) {
  462. console.error('[U-NEXT Skip Intro] Cannot remove skip button. Page content maybe changed?', e);
  463. }
  464. observer.disconnect();
  465. window.removeEventListener('keyup', skipCreditPress)
  466. window.removeEventListener('keyup', nextEpisodePress)
  467. removeButtonStyle();
  468. clearTimeout(lastPlayTimeThrottle);
  469. lastPlayTimeThrottle = null;
  470. skipCreditsButton = null;
  471. }
  472. };
  473. }
  474.  
  475. window.addEventListener('reactRouterUrlChange', () => {
  476. document.querySelector('#introskip-btn-skip')?.remove();
  477. removeButtonStyle();
  478. clearTimeout();
  479. initializeGlobalVar();
  480. setTimeout(waitForVideoElement, 1000);
  481. });
  482.  
  483. // Function to wait until the video element is available
  484. function waitForVideoElement() {
  485. const video = document.getElementsByTagName("video")[0];
  486. if (video && moviePartsObjectInitialized) {
  487. handleParseSkipDuration()
  488. console.log(introObject, creditObject)
  489. addSkipButtonsToVideo(video);
  490. } else {
  491. // Retry after 500ms if video element is not found
  492. setTimeout(waitForVideoElement, 500);
  493. }
  494. }
  495.  
  496. listenReactUrlChange();
  497. waitForVideoElement();
  498.  
  499. })();