beypazarigurusu / Twitch Pitch Change Fix

// ==UserScript==
// @name        Twitch Pitch Change Fix
// @description Disables auto playback rate change to avoid sound pitch shifts.
// @version     1.0
// @author      beypazarigurusu
// @license     MIT
// @match       https://*.twitch.tv/*
// @grant       none
// @icon        https://www.google.com/s2/favicons?domain=twitch.tv
// @updateURL   https://openuserjs.org/meta/beypazarigurusu/Twitch_Pitch_Change_Fix.meta.js
// @downloadURL https://openuserjs.org/install/beypazarigurusu/Twitch_Pitch_Change_Fix.user.js
// ==/UserScript==

const PLAYBACK_SPEED = 1 // Try slowing down to debug

const TAG = '[Twitch Pitch Fix]'
const DATA_ATTRIBUTE = 'data-pitchfix-attached'
const FOUND_IDENTIFIER = 'data-pitchfix-found'

const INDICATOR_NEXT_NEIGHBOR_SELECTOR = 'p[data-a-target="animated-channel-viewers-count"]'

let renderLatencyIndicatorCalled = false
let latencyIndicatorRendered = false
let latencyIndicatorInterval = null

function waitForKeyElements(identifier, selectorOrFunction, callback, waitOnce, interval, maxIntervals) {
  if (typeof waitOnce === "undefined") {
    waitOnce = true
  }
  if (typeof interval === "undefined") {
    interval = 300
  }
  if (typeof maxIntervals === "undefined") {
    maxIntervals = -1
  }
  const targetNodes = (typeof selectorOrFunction === "function") ?
    selectorOrFunction() :
    document.querySelectorAll(selectorOrFunction)

  const targetsFound = targetNodes && targetNodes.length > 0
  if (targetsFound) {
    targetNodes.forEach(function (targetNode) {
      const alreadyFound = targetNode.getAttribute(identifier) || false
      if (!alreadyFound) {
        const cancelFound = callback(targetNode)
        if (cancelFound) {
          targetsFound = false
        }
        else {
          targetNode.setAttribute(identifier, true)
        }
      }
    })
  }

  if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
    maxIntervals -= 1
    setTimeout(function () {
      waitForKeyElements(identifier, selectorOrFunction, callback, waitOnce, interval, maxIntervals)
    }, interval)
  }
}

function debounce(func, wait, immediate) {
  let timeout
  return function () {
    const context = this
    const args = arguments
    const later = function () {
      timeout = null
      if (!immediate) func.apply(context, args)
    }
    const callNow = immediate && !timeout
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
    if (callNow) func.apply(context, args)
  }
}

function init() {
  if (!document.URL.includes('twitch.tv/directory') && !document.URL.includes('/video')) {
    const videos = document.getElementsByTagName('VIDEO')
    if (videos.length > 0) {
      run(videos[0])
    }
    else {
      waitForKeyElements(FOUND_IDENTIFIER, 'video[src]', run, true)
    }
  }
}

function run() {
  const playerContainerEl = document.querySelector('.video-player')
  const found = search(playerContainerEl)
  const instances = Array.from(found.instances)
  if (instances.length > 0) {
    for (const inst of instances) {
      if (inst.props && inst.props && inst.props.mediaPlayerInstance) {
        const player = inst.props.mediaPlayerInstance
        // player.setLiveSpeedUpRate(1) // Not working anymore
        const el = player.getHTMLVideoElement()
        attach(el)
        if (player.getLiveLatency && !renderLatencyIndicatorCalled) {
          renderLatencyIndicatorCalled = true
          renderLatencyIndicator(player)
        }
      }
    }
  }
}

function renderLatencyIndicator(playerInst) {
  if (latencyIndicatorRendered) {
    return
  }
  const getLatency = () => playerInst.getLiveLatency().toFixed(2)
  waitForKeyElements('LIVE_TIME', INDICATOR_NEXT_NEIGHBOR_SELECTOR, (nextNeighborEl) => {
    latencyIndicatorRendered = true
    const indicatorEl = nextNeighborEl.cloneNode(false)
    indicatorEl.onclick = () => {
      playerInst.pause()
      playerInst.play()
    }
    indicatorEl.className = 'tw-strong latency-indicator'
    indicatorEl.title = 'Latency'
    const indicatorTextEl = document.createElement('span')
    indicatorTextEl.innerText = `Latency: ${getLatency()}s`
    indicatorEl.appendChild(indicatorTextEl)
    latencyIndicatorInterval = setInterval(() => {
      let latency = getLatency()
      indicatorTextEl.innerText = `Latency: ${getLatency()}s`
      if (latency > 5) {
        indicatorEl.classList.add("high-latency")
      }
      else {
        indicatorEl.classList.remove("high-latency")
      }
    }, 1000 * 3)
    const container = nextNeighborEl.parentNode
    container.insertBefore(indicatorEl, container.firstChild)
  }, true)
}

function attach(videoEl) {
  if (videoEl.getAttribute(DATA_ATTRIBUTE)) {
    return
  }
  console.debug(TAG, 'Attaching:', videoEl)
  videoEl.setAttribute(DATA_ATTRIBUTE, true)
  try {
    overrideSetPlaybackRate(videoEl)
    videoEl.onplaying = videoEl.onloadedmetadata = debounce(() => {
      overrideSetPlaybackRate(videoEl)
    }, 1000)
  }
  catch (e) {}
}

function overrideSetPlaybackRate(videoEl) {
  delete videoEl.playbackRate
  videoEl.playbackRate = PLAYBACK_SPEED
  Object.defineProperty(videoEl, 'playbackRate', {
    configurable: true,
    get: function () {
      return 1
    },
    set: function (val) {
      console.debug(TAG, 'Ignored setPlaybackRate request from Twitch:', val)
    }
  })
}

function findAccessor(element) {
  return Object.keys(element)
    .find(key => key.startsWith('__reactInternalInstance$'))
}

function getReactInstance(el) {
  const accessor = findAccessor(el)
  if (!accessor) {
    return
  }
  return el[accessor] ||
    (el._reactRootContainer && el._reactRootContainer._internalRoot && el._reactRootContainer._internalRoot.current) ||
    (el._reactRootContainer && el._reactRootContainer.current)
}

function search(node, depth = 0, data, traverseRoots = true) {
  if (!node) {
    node = null
  }
  else if (node._reactInternalFiber) {
    node = node._reactInternalFiber
  }
  else if (node instanceof Node) {
    node = getReactInstance(node)
  }

  const maxDepth = 1000
  const filterFn = c => c.props?.playerEvents && c.props?.mediaPlayerInstance

  if (!data) {
    data = {
      seen: new Set(),
      class: null,
      out: {
        cls: null,
        instances: new Set(),
        depth: null
      },
      maxDepth: depth
    }
  }

  if (!node || depth > maxDepth) {
    return data.out
  }

  if (depth > data.maxDepth) {
    data.maxDepth = depth
  }

  const inst = node.stateNode
  if (inst) {
    const cls = inst.constructor

    if (!data.seen.has(cls)) {
      if (filterFn(inst)) {
        data.class = data.out.cls = cls
        data.out.instances.add(inst)
        data.out.depth = depth
      }
      data.seen.add(cls)
    }
  }

  let child = node.child
  while (child) {
    search(child, depth + 1, data, traverseRoots)
    child = child.sibling
  }

  if (traverseRoots && inst && inst.props && inst.props.root) {
    const root = inst.props.root._reactRootContainer
    if (root) {
      let child = root._internalRoot && root._internalRoot.current || root.current
      while (child) {
        search(child, depth + 1, data, traverseRoots)
        child = child.sibling
      }
    }
  }

  return data.out
}

(function addStyle() {
  const style = `
    .latency-indicator {
      margin-right: 10px;
      color: var(--color-text-base);
      padding: 5px 8px 5px 8px;
      background: var(--color-background-button-secondary-default);
      border-radius: 5px;
      cursor: pointer;
    }
    .latency-indicator:hover {
      background: #9147ff;
      color: var(--color-text-button);
    }
    .latency-indicator:hover span {
      display: none;
    }
    .latency-indicator:hover:before {
      content: 'Reset Player';
    }
    .high-latency {
      color: var(--color-text-live);
    }
  `
  const styleSheet = document.createElement('style')
  styleSheet.innerText = style
  document.head.appendChild(styleSheet)
}())

const ogPushState = history.pushState
history.pushState = function() {
  ogPushState.apply(history, arguments)
  clearInterval(latencyIndicatorInterval)
  init()
}

const ogReplaceState = history.replaceState
history.replaceState = function() {
  ogReplaceState.apply(history, arguments)
  clearInterval(latencyIndicatorInterval)
  init()
}

window.addEventListener('popstate', function() {
  clearInterval(latencyIndicatorInterval)
  init()
})

init()