Twitch Scroll Wheel Volume

Scroll wheel volume control for Twitch

  1. // ==UserScript==
  2. // @name Twitch Scroll Wheel Volume
  3. // @description Scroll wheel volume control for Twitch
  4. // @include https://www.twitch.tv/*
  5. // @include /^https:\/\/(?!supervisor).*\.ext-twitch\.tv\/.*anchor=video_overlay.*$/
  6. // @run-at document-idle
  7. // @allFrames true
  8. // @version 0.0.1.20241130074535
  9. // @namespace https://greatest.deepsurf.us/users/286737
  10. // ==/UserScript==
  11.  
  12. class Player {
  13. constructor() {
  14. this.wheelVolume = new WheelVolume()
  15. }
  16.  
  17. async init() {
  18. let $root
  19.  
  20. while (!($root = $('.root-scrollable__wrapper'))) await wait(2000)
  21.  
  22. this.$root = $root
  23.  
  24. const onRootMutation = this.onRootMutation.bind(this)
  25.  
  26. new MutationObserver(onRootMutation).observe($root, {childList: true})
  27.  
  28. onRootMutation()
  29. }
  30.  
  31. onRootMutation() {
  32. const $player = $('.persistent-player', this.$root)
  33.  
  34. if ($player == this.$player) return
  35.  
  36. this.$player = $player
  37.  
  38. if ($player) this.onNewPlayer()
  39. }
  40.  
  41. async onNewPlayer() {
  42. const api = await this.getApi()
  43. const $eventCatcher = $('.video-ref', this.$player)
  44.  
  45. this.wheelVolume.init(api, $eventCatcher)
  46. }
  47.  
  48. async getApi() {
  49. const playerSel = 'div[data-a-target="player-overlay-click-handler"], .video-player'
  50. let $el, api
  51.  
  52. while (!($el = $(playerSel, unsafeWindow.document))) await wait(2000)
  53. while (!(api = this.getReactPlayerApi($el))) await wait(500)
  54.  
  55. return api
  56. }
  57.  
  58. getReactPlayerApi($el) {
  59. let instance
  60.  
  61. for (const key in $el) {
  62. if (key.startsWith('__reactInternalInstance$') || key.startsWith('__reactFiber$')) {
  63. instance = $el[key]
  64. }
  65. }
  66.  
  67. let parent = instance.return
  68.  
  69. for (let i = 0; i < 50; i++) {
  70. const player = parent.memoizedProps.mediaPlayerInstance
  71.  
  72. if (player) return player.core
  73.  
  74. parent = parent.return
  75. }
  76. }
  77. }
  78.  
  79. class WheelVolume {
  80. constructor() {
  81. this.onWheelHandler = this.onWheel.bind(this)
  82. this.onMousedownHandler = this.onMousedown.bind(this)
  83.  
  84. this.events = {
  85. mouseout: new Event('mouseout', {bubbles: true}),
  86. focusin: new Event('focusin', {bubbles: true})
  87. }
  88.  
  89. const onExtMessage = this.onExtMessage.bind(this)
  90.  
  91. addEventListener('message', onExtMessage)
  92. }
  93.  
  94. init(api, $eventCatcher) {
  95. this.api = api
  96. this.$eventCatcher = $eventCatcher
  97.  
  98. $eventCatcher.addEventListener('wheel', this.onWheelHandler)
  99. $eventCatcher.addEventListener('mousedown', this.onMousedownHandler)
  100. }
  101.  
  102. onWheel(e) {
  103. e.preventDefault()
  104. e.stopImmediatePropagation()
  105.  
  106. this.updateVolume(e.deltaY < 0)
  107. }
  108.  
  109. onMousedown(e) {
  110. if (e.which != 2) return
  111.  
  112. e.preventDefault()
  113.  
  114. this.toggleMute()
  115. }
  116.  
  117. onExtMessage(e) {
  118. const event = e.data.wheelEvent
  119.  
  120. if (!event) return
  121.  
  122. switch (event) {
  123. case 'up':
  124. this.updateVolume(true)
  125. break
  126. case 'down':
  127. this.updateVolume(false)
  128. break
  129. case 'click':
  130. this.toggleMute()
  131. }
  132. }
  133.  
  134. updateVolume(shouldIncrease) {
  135. this.show()
  136.  
  137. const api = this.api, volume = api.getVolume()
  138.  
  139. if (api.isMuted()) api.setMuted(false)
  140.  
  141. if ((volume == 0 && !shouldIncrease) || (volume == 1 && shouldIncrease)) return
  142.  
  143. const now = Date.now(), since = now - this.prevScrollDate
  144. const step = (shouldIncrease ? 1 : -1) * (since < 50 ? 4 : 1) * .01
  145.  
  146. api.setVolume(volume + step)
  147.  
  148. this.prevScrollDate = now
  149. }
  150.  
  151. toggleMute() {
  152. this.show()
  153.  
  154. const api = this.api
  155.  
  156. api.setMuted(!api.isMuted())
  157. }
  158.  
  159. show() {
  160. let $volumeBar = this.$volumeBar
  161.  
  162. if (!this.$eventCatcher.contains($volumeBar)) {
  163. $volumeBar = this.$volumeBar = $('[data-a-target="player-volume-slider"]', this.$eventCatcher)
  164.  
  165. if (!$volumeBar) return
  166. }
  167.  
  168. const events = this.events
  169.  
  170. clearTimeout(this.showTimeout)
  171.  
  172. $volumeBar.dispatchEvent(events.focusin)
  173.  
  174. this.showTimeout = setTimeout(() => $volumeBar.dispatchEvent(events.mouseout), 1000)
  175. }
  176. }
  177.  
  178. class ExtFrame {
  179. init() {
  180. const onWheel = this.onWheel.bind(this)
  181. const onMousedown = this.onMousedown.bind(this)
  182.  
  183. addEventListener('wheel', onWheel, {passive: false})
  184. addEventListener('mousedown', onMousedown, {passive: false})
  185. }
  186.  
  187. onWheel(e) {
  188. e.preventDefault()
  189. e.stopPropagation()
  190.  
  191. this.sendEvent(e.deltaY < 0 ? 'up' : 'down')
  192. }
  193.  
  194. onMousedown(e) {
  195. if (e.which != 2) return
  196.  
  197. e.preventDefault()
  198.  
  199. this.sendEvent('click')
  200. }
  201.  
  202. sendEvent(name) {
  203. parent.postMessage({wheelEvent: name}, 'https://supervisor.ext-twitch.tv/')
  204. }
  205. }
  206.  
  207. const init = () => {
  208. if (location.host == 'www.twitch.tv') return new Player().init()
  209.  
  210. new ExtFrame().init()
  211. }
  212.  
  213.  
  214. const $ = (sel, el = document) => el.querySelector(sel)
  215.  
  216. const wait = async (ms) => await new Promise(r => setTimeout(r, ms))
  217.  
  218.  
  219. init()