U-NEXT Skip Intro

Add missing skip intro/credit to U-NEXT player

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name               U-NEXT Skip Intro
// @name:zh-CN         U-NEXT 跳过片头
// @name:ja            U-NEXT イントロスキップ
// @namespace          http://tampermonkey.net/
// @match              https://*.unext.jp/*
// @run-at             document-start
// @grant              unsafeWindow
// @version            1.2
// @author             DiruSec
// @license            MIT
// @icon               https://www.google.com/s2/favicons?sz=64&domain=unext.jp
// @description        Add missing skip intro/credit to U-NEXT player
// @description:zh-CN  给 U-NEXT 添加跳过片头/演职人员表的功能
// @description:ja     U-NEXT に「イントロ/クレジットをスキップ」機能を追加
// ==/UserScript==

(function () {
  'use strict';
  // define default variables
  let introObject = {
    startDuration: null,
    endDuration: null
  }
  let creditObject = {
    startDuration: null,
    endDuration: null
  }
  let moviePartsPositionList = []
  let episodeDuration = null
  let lastPlayTimeThrottle = null
  let playerPanelNode = null
  let hideSkipButtonWithPanel = false
  let moviePartsObjectInitialized = false
  let nextEpisodeObject = {
    titleCode: null,
    episodeCode: null,
    displayNo: null,
    episodeName: null,
    thumbnail: null,
    getPlayUrl() { return this.titleCode && this.episodeCode ? `https://video.unext.jp/play/${this.titleCode}/${this.episodeCode}` : null},
    getDisplayTitle() {return `${this.displayNo}\n${this.episodeName}`},
  }

  function initializeGlobalVar() {
    introObject = {
      startDuration: null,
      endDuration: null
    }
    creditObject = {
      startDuration: null,
      endDuration: null
    }
    moviePartsPositionList = []
    episodeDuration = null
    lastPlayTimeThrottle = null
    playerPanelNode = null
    hideSkipButtonWithPanel = false
    moviePartsObjectInitialized = false
    nextEpisodeObject = {
      titleCode: null,
      episodeCode: null,
      displayNo: null,
      episodeName: null,
      thumbnail: null,
      getPlayUrl() { return this.titleCode && this.episodeCode ? `https://video.unext.jp/play/${this.titleCode}/${this.episodeCode}` : null},
      getDisplayTitle() {return `${this.displayNo}\n${this.episodeName}`},
    }
  }

  function listenReactUrlChange() {
    // Save references to the original methods
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    // Utility function to handle URL changes
    function onUrlChange() {
      console.log('React Router URL changed:', window.location.href);

      // You can trigger a custom event or callback here
      const urlChangeEvent = new Event('reactRouterUrlChange');
      window.dispatchEvent(urlChangeEvent);
    }

    // Override pushState
    history.pushState = function(...args) {
      originalPushState.apply(this, args);
      onUrlChange(); // Trigger the function on URL change
    };

    // Override replaceState
    history.replaceState = function(...args) {
      originalReplaceState.apply(this, args);
      onUrlChange(); // Trigger the function on URL change
    };
  }

  function preProcessRequest(requestOptions) {
    // condition checks
    if (!(requestOptions?.method === 'POST' && requestOptions?.headers['content-type'] === 'application/json' && requestOptions.body)) {
      return requestOptions;
    }

    let requestBody;
    try {
      requestBody = JSON.parse(requestOptions.body);
    } catch (e) {
      console.error('[U-NEXT Skip Intro] invaild graphql request body found');
      return requestOptions;
    }

    return requestOptions

  }

  function replaceGraphql(requestBody) {
    // replaces graphql to add intro/credit parts query
    const searchString = 'commodityCode\n      movieAudioList {\n        audioType\n        __typename\n      }\n      ';
    if (!requestBody.query || !requestBody.query.includes(searchString)) {
      return requestBody;
    }

    const replaceString = `${searchString}moviePartsPositionList {\n        hasRemainingPart\n        to\n        from\n        __typename\n      }\n      `;
    requestBody.query = requestBody.query.replace(searchString, replaceString);
    return requestBody
  }

  async function handleGetNextEpisode(response) {
    try {
      const jsonData = await response.json();
      const data = jsonData.data?.webfront_postPlay;

      if (!data || !data.nextEpisode) {
        console.warn('[U-NEXT Skip Intro] No next episode information found.');
        return null;
      }

      const { titleCode, episodeCode, displayNo, episodeName, thumbnail } = data.nextEpisode;

      return {
        titleCode,
        episodeCode,
        displayNo,
        episodeName,
        thumbnail: thumbnail.standard,
      };
    } catch (e) {
      console.error('[U-NEXT Skip Intro] Error parsing response:', e);
      return null;
    }
  }


  async function handleGetSkipDuration(response) {
    try {
      const jsonData = await response.json();
      const data = jsonData.data?.webfront_playlistUrl?.urlInfo && jsonData.data?.webfront_playlistUrl?.urlInfo[0];

      if (!data || !data.moviePartsPositionList) {
        console.warn('[U-NEXT Skip Intro] No moviePartsPositionList information found.');
        return null;
      }

      return data.moviePartsPositionList || [];
    } catch (e) {
      console.error('[U-NEXT Skip Intro] Error parsing response:', e);
      return [];
    }
  }

  function handleParseSkipDuration() {
    console.log('moviePartsPositionList', moviePartsPositionList)
    if (moviePartsPositionList.length === 0) return;

    // If there's only one part, compare 'from' with video duration/2
    if (moviePartsPositionList.length === 1) {
      const part = moviePartsPositionList[0];
      part.startDuration = Number(part.fromSeconds);
      part.endDuration = Number(part.endSeconds);
      part.duration = part.endDuration - part.startDuration;

      if (part.type === 'OPENING') {
        introObject.startDuration = part.startDuration
        introObject.endDuration = part.endDuration
        part.label = 'Intro';
      } else {
        creditObject.startDuration = part.startDuration
        creditObject.endDuration = part.endDuration
        part.label = 'Credits';

        part.hasRemainingPart === false && (creditObject.hasRemainingPart = false);
      }
    } else {
      // Logic for more than one part
      let introPart = moviePartsPositionList[0];
      let creditsPart = moviePartsPositionList[0];

      moviePartsPositionList.forEach(part => {
        part.startDuration = Number(part.fromSeconds);
        part.endDuration = Number(part.endSeconds);
        part.duration = part.endDuration - part.startDuration;

        // Find the earliest 'from' value for the intro
        if (part.startDuration < introPart.startDuration) {
          introPart = part;
        }

        // Find the latest 'to' value for the credits
        if (part.endDuration > creditsPart.endDuration) {
          creditsPart = part;
        }
      });

      introObject.startDuration = introPart.startDuration
      creditObject.startDuration = creditsPart.startDuration
      introObject.endDuration = introPart.endDuration
      creditObject.endDuration = creditsPart.endDuration
      creditObject.hasRemainingPart = creditsPart.hasRemainingPart
      // Assign labels
      introPart.label = 'Intro';
      creditsPart.label = 'Credits';
    }
  }

  // Save the original fetch function
  const originalFetch = window.fetch;

  // Override the fetch function
  const newFetch = async function (...args) {
    const url = args[0];

    // Check if the URL matches the pattern
    const regex = /^https:\/\/cc\.unext\.jp\/\?/;
    const getPlaylistUrlStr = 'operationName=cosmo_getPlaylistUrl';
    const getPostPlayStr = 'operationName=cosmo_getPostPlay';

    if (regex.test(url)) {

      //let requestOptions = args[1];
      //args[1] = preProcessRequest(requestOptions)

      // need to get something from response
      const response = await originalFetch(...args);
      const responseClone = response.clone()

      try {
        //const requestBody = JSON.parse(requestOptions.body);

        if (url.indexOf(getPlaylistUrlStr) !== -1) {
          let skipDuration = await handleGetSkipDuration(responseClone);

          moviePartsPositionList = skipDuration
          moviePartsObjectInitialized = true
        } else if (url.indexOf(getPostPlayStr) !== -1) {
          let nextEpisode = await handleGetNextEpisode(responseClone);
          nextEpisode && (
            nextEpisodeObject.titleCode = nextEpisode.titleCode,
            nextEpisodeObject.episodeCode = nextEpisode.episodeCode,
            nextEpisodeObject.displayNo = nextEpisode.displayNo,
            nextEpisodeObject.episodeName = nextEpisode.episodeName,
            nextEpisodeObject.thumbnail = nextEpisode.thumbnail.standard
          )
        }
      } catch (e) {
        console.error('[U-NEXT Skip Intro] Error handling operationName:', e);
      }

      // Return original Response object with no modification
      return response;
    }

    // If the URL doesn't match, return the original fetch call
    return originalFetch(...args);
  };

  Object.defineProperty(unsafeWindow, 'fetch', { value: newFetch, enumerable: false, writable: true });

// Function to create a button dynamically
function createSkipButton(text, onClick) {
  const isPanelDisplayed = window.getComputedStyle(document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement, null).getPropertyValue('opacity') === '1'
  const button = document.createElement('button');
  button.id = 'introskip-btn-skip';
  button.innerText = text;
  button.style.position = 'absolute';
  button.style.bottom = isPanelDisplayed? '9.6rem': '3rem';
  button.style.right = '2rem';
  button.style.zIndex = '1000';
  button.addEventListener('click', onClick);
  createButtonStyle();
  return button;
}

function createButtonStyle() {
  const style = document.createElement('style');
  style.innerHTML = `
      #introskip-btn-skip {
        background-color: #0F0F0FFF;
        color: #EEE;
        border: solid;
        border-color: #666;
        border-width: .1rem;
        border-radius: .2rem;
        cursor: pointer;
        padding: 1rem 2rem;
        opacity: 1;
        transition: all 0.2s ease;
      }

      #introskip-btn-skip:hover {
        background-color: #0F0F0F99;
        transform: scale(1.05);
      }

      #introskip-btn-skip.hide {
        opacity: 0;
        display: none;
      }
  `
  document.head.appendChild(style)
}

function removeButtonStyle() {
  const styleSheets = document.head.querySelectorAll('style');

  styleSheets.forEach(styleSheet => {
    if (styleSheet.innerHTML.includes('#introskip-btn-skip')) {
      styleSheet.remove();
    }
  });
}

function setHideSkipButtonWithPanel() {
  hideSkipButtonWithPanel = true;
  playerPanelNode = document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement;
  let isDisplayed = window.getComputedStyle(playerPanelNode, null).getPropertyValue('opacity') === '1'
  document.querySelector('#introskip-btn-skip').className = hideSkipButtonWithPanel&&!isDisplayed?'hide':''
}

// Function to add event listeners to the video
function addSkipButtonsToVideo(video) {
  let skipIntroButton = null;
  let skipCreditsButton = null;

  const callback = (mutationsList, observer) => {
    mutationsList.forEach((mutationObj) => {
      if (mutationObj.attributeName === 'class') {
        // for mutationsObserver, when opacity starts change, value will be the last moment before changes.
        let isDisplayed = window.getComputedStyle(playerPanelNode, null).getPropertyValue('opacity') === '0'
        let skipBtnDom = document.querySelector('#introskip-btn-skip')
        skipBtnDom && (skipBtnDom.className = hideSkipButtonWithPanel&&!isDisplayed?'hide':'')
        skipBtnDom && (skipBtnDom.style.bottom = isDisplayed?'9.6rem':'3rem')
      }
    })
  };

  const observer = new MutationObserver(callback);

  const config = { attributes: true, childList: false, subtree: false };

  const skipIntroPress = event => {
    event.code === 'KeyS' && (video.currentTime = introObject.endDuration);
  }
  const skipCreditPress = event => {
    event.code === 'KeyS' && (video.currentTime = creditObject.endDuration);
  }
  const nextEpisodePress = event => {
    event.code === 'KeyS' && (window.location.href = nextEpisodeObject.getPlayUrl());
  }
  // Get the episode duration
  video.ondurationchange = function () {
    episodeDuration = video.duration;
    console.log(`Episode Duration: ${episodeDuration}`);
  };

  // Listen to ontimeupdate event
  video.ontimeupdate = function () {
    const currentTime = video.currentTime;

    // Skip Intro Button
    if (currentTime >= introObject.startDuration && currentTime <= introObject.endDuration) {
      if (introObject.endDuration - currentTime >= 5 && !lastPlayTimeThrottle) {
        lastPlayTimeThrottle = setTimeout(setHideSkipButtonWithPanel, 5000)
      }
      if (!skipIntroButton) {
        playerPanelNode = document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement;

        skipIntroButton = createSkipButton('SKIP INTRO', ()=> {
          video.currentTime = introObject.endDuration;
        });
        window.addEventListener('keyup', skipIntroPress)

        document.querySelector('#videoFullScreenWrapper').appendChild(skipIntroButton);

        if (playerPanelNode) {
          observer.observe(playerPanelNode, config);
        } else {
          console.error("Target node not found.");
        }
      }
    } else if (skipIntroButton) {
      try {
        document.querySelector('#videoFullScreenWrapper').removeChild(skipIntroButton);
      } catch (e) {
        console.error('[U-NEXT Skip Intro] Cannot remove skip button. Page content maybe changed?', e);
      }
      observer.disconnect()
      window.removeEventListener('keyup', skipIntroPress)
      removeButtonStyle();
      clearTimeout(lastPlayTimeThrottle);
      lastPlayTimeThrottle = null;
      skipIntroButton = null;
    }

    // Skip Credits or Next Episode Button
    if (currentTime >= creditObject.startDuration && currentTime <= creditObject.endDuration) {
      const timeDifference = episodeDuration - creditObject.endDuration;
      playerPanelNode = document.querySelector('button[data-testid="player-header-back"]').parentElement.parentElement;

      if (creditObject.endDuration - currentTime >= 5 && !lastPlayTimeThrottle) {
        lastPlayTimeThrottle = setTimeout(setHideSkipButtonWithPanel, 5000)
      }

      // Show "Next Episode" if the time difference is <= 10 seconds

      if (creditObject.hasRemainingPart === false) {
        if (!skipCreditsButton || skipCreditsButton.innerText !== 'NEXT EPISODE') {
          if (skipCreditsButton) document.querySelector('#videoFullScreenWrapper').removeChild(skipCreditsButton);
          skipCreditsButton = createSkipButton('NEXT EPISODE', () => {
            window.location.href = nextEpisodeObject.getPlayUrl();
          });
          document.querySelector('#videoFullScreenWrapper').appendChild(skipCreditsButton);
          window.addEventListener('keyup', nextEpisodePress)

          if (playerPanelNode) {
            observer.observe(playerPanelNode, config);
          } else {
            console.error("Target node not found.");
          }
        }
      }
      // Otherwise, show "Skip Credits"
      else {
        if (!skipCreditsButton || skipCreditsButton.innerText !== 'SKIP CREDITS') {
          if (skipCreditsButton) document.querySelector('#videoFullScreenWrapper').removeChild(skipCreditsButton);
          skipCreditsButton = createSkipButton('SKIP CREDITS', () => {
            video.currentTime = creditObject.endDuration;
          });
          document.querySelector('#videoFullScreenWrapper').appendChild(skipCreditsButton);
          window.addEventListener('keyup', skipCreditPress)

          if (playerPanelNode) {
            observer.observe(playerPanelNode, config);
          } else {
            console.error("Target node not found.");
          }
        }
      }
    } else if (skipCreditsButton) {
      try {
        document.querySelector('#videoFullScreenWrapper').removeChild(skipCreditsButton);
      } catch (e) {
        console.error('[U-NEXT Skip Intro] Cannot remove skip button. Page content maybe changed?', e);
      }
      observer.disconnect();
      window.removeEventListener('keyup', skipCreditPress)
      window.removeEventListener('keyup', nextEpisodePress)
      removeButtonStyle();
      clearTimeout(lastPlayTimeThrottle);
      lastPlayTimeThrottle = null;
      skipCreditsButton = null;
    }
  };
}

window.addEventListener('reactRouterUrlChange', () => {
  document.querySelector('#introskip-btn-skip')?.remove();
  removeButtonStyle();
  clearTimeout();
  initializeGlobalVar();
  setTimeout(waitForVideoElement, 1000);
});

// Function to wait until the video element is available
function waitForVideoElement() {
  const video = document.getElementsByTagName("video")[0];
  if (video && moviePartsObjectInitialized) {
    handleParseSkipDuration()
    console.log(introObject, creditObject)
    addSkipButtonsToVideo(video);
  } else {
    // Retry after 500ms if video element is not found
    setTimeout(waitForVideoElement, 500);
  }
}

listenReactUrlChange();
waitForVideoElement();

})();