Skip to content

Instantly share code, notes, and snippets.

@juliendargelos
Last active July 31, 2026 21:15
Show Gist options
  • Select an option

  • Save juliendargelos/0f17385f3ce9ce2c59e936a8b8ac9e44 to your computer and use it in GitHub Desktop.

Select an option

Save juliendargelos/0f17385f3ce9ce2c59e936a8b8ac9e44 to your computer and use it in GitHub Desktop.
Userscript - Open twitch streams and videos in IINA

Userscript - Open twitch streams and videos in IINA

preview

This userscript allows you to open streams and videos from the twitch website in IINA. When clicking on a stream or video, a menu appears giving you the choice to play it either normally in the browser, or in the IINA media player. You are then free to close your browser window if you want.

On first use, the browser may ask confirmation to open the IINA app, click on "Always allow" if you don't want to have to confirm the next times.

A userscripts browser extension is required to use this userscript. On Safari, you can use the open source Userscripts extension (github repository).

Benefits of using the iina media player to watch Twitch:

  • Hidden ads when playing streams, they simply mute and display a generic "Commercial break in progress" card
  • No ads at all when playing videos
  • Easy use of native macOS features such as picture-in-picture and fullscreen
  • Improved performances, only the raw decoded video stream, no running browser required
  • Decluttered user interface with no chat or distracting animations
// ==UserScript==
// @name Twitch - Open in IINA
// @match https://www.twitch.tv
// @match https://www.twitch.tv/*
// ==/UserScript==
;(() => {
const ID = `iina-${Date.now()}-${Math.floor(Math.random() * 1000000)}`
const MENU_VIEWPORT_MARGIN = 5
const MENU_POINTER_MARGIN = 5
const VIDEO_URL_PATTERN = /^https:\/\/www\.twitch\.tv\/(?:videos\/)?([^\/]+)$/
const RESERVED_USERNAMES = [
'directory',
'settings',
'privacy',
'subscriptions',
'inventory',
'wallet',
'settings',
'downloads',
'jobs',
'store',
'turbo'
]
const style = document.createElement('style')
style.textContent = `
.${ID}-menu {
min-width: 150px;
background-color: rgb(50, 50, 57);
border-radius: 6px;
box-shadow: rgba(0, 0, 0, 0.6) 0px 4px 8px 0px, rgba(0, 0, 0, 0.4) 0px 0px 4px 0px;
text-align: center;
position: fixed;
left: -1000px;
top: -1000px;
padding: 8px;
transition: opacity .2s, transform .2s;
z-index: 1000000;
&[inert] {
opacity: 0;
transform: scale(.95);
pointer-events: none;
}
button {
width: 100%;
background-color: transparent;
appearance: none;
position: relative;
padding: 6px;
font-size: 14px;
font-weight: 400;
line-height: 19.6px;
color: rgb(239, 239, 241);
border: none;
outline: none;
border-radius: 4px;
cursor: pointer;
display: block;
text-align: start;
&:hover {
background-color: rgba(83, 83, 95, 0.48);
}
}
}
`
document.head.appendChild(style)
const menuElement = document.createElement('div')
menuElement.className = `${ID}-menu`
menuElement.inert = true
const openInIINAButton = document.createElement('button')
const openInBrowserButton = document.createElement('button')
openInIINAButton.textContent = 'Open in IINA'
openInBrowserButton.textContent = 'Open in browser'
menuElement.appendChild(openInIINAButton)
menuElement.appendChild(openInBrowserButton)
document.body.appendChild(menuElement)
let link
let pointerX = 0
let pointerY = 0
let openingInBrowser = false
menuElement.addEventListener('transitionend', () => {
if (menuElement.inert) {
menuElement.style.top = ''
menuElement.style.left = ''
}
})
openInIINAButton.addEventListener('click', () => {
openingInBrowser = false
const url = new URL('iina://open')
url.searchParams.set('url', link.href)
url.searchParams.set('new_window', '0')
location.href = url.href
closeMenu()
})
openInBrowserButton.addEventListener('click', () => {
openingInBrowser = true
link.click()
closeMenu()
})
addEventListener('click', (event) => {
if (menuElement.contains(event.target)) {
return
}
if (openingInBrowser) {
openingInBrowser = false
return
}
link = event.target.closest('a')
if (!link || !link.href) {
return
}
const match = link.href.match(VIDEO_URL_PATTERN)
if (!match) {
return
}
if (RESERVED_USERNAMES.includes(match[1])) {
return
}
if (
!link.href.startsWith('https://www.twitch.tv/videos/') &&
!link.querySelector('.tw-channel-status-text-indicator, .tw-channel-status-indicator') &&
!(
link.getAttribute('data-a-target') === 'preview-card-channel-link' &&
link.closest('article').querySelector('a[data-a-target="preview-card-image-link"] .tw-channel-status-text-indicator')
)
) {
return
}
event.preventDefault()
openMenu()
}, {
capture: true
})
addEventListener('pointerdown', onPointerdown)
function openMenu() {
const rect = menuElement.getBoundingClientRect()
let originX
let originY
if (pointerX + MENU_POINTER_MARGIN + rect.width + MENU_VIEWPORT_MARGIN > window.innerWidth) {
menuElement.style.left = `${pointerX - MENU_POINTER_MARGIN - rect.width}px`
originX = 'right'
} else {
menuElement.style.left = `${pointerX + MENU_POINTER_MARGIN}px`
originX = 'left'
}
if (pointerY + MENU_POINTER_MARGIN + rect.height + MENU_POINTER_MARGIN > window.innerHeight) {
menuElement.style.top = `${pointerY - MENU_POINTER_MARGIN - rect.height}px`
originY = 'bottom'
} else {
menuElement.style.top = `${pointerY + MENU_POINTER_MARGIN}px`
originY = 'top'
}
menuElement.style.transformOrigin = `${originX} ${originY}`
menuElement.inert = false
addEventListener('keydown', onKeydown)
addEventListener('scroll', onScroll, { once: true, capture: true })
}
function closeMenu() {
menuElement.inert = true
removeEventListener('keydown', onKeydown)
removeEventListener('scroll', onScroll)
}
function onPointerdown(event) {
pointerX = event.clientX
pointerY = event.clientY
if (!menuElement.inert && !menuElement.contains(event.target)) {
closeMenu()
}
}
function onKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault()
closeMenu()
}
}
function onScroll() {
closeMenu()
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment