// ==UserScript==
// @name Twitch & Kick Latency
// @namespace latency
// @version 1.5.18
// @description Displays Twitch & Kick latency with proper player reload and MiniPlayer
// @author frz
// @icon https://www.allkeyshop.com/blog/wp-content/uploads/Twitch-vs-Kick_featured.png
// @match https://www.twitch.tv/*
// @match https://kick.com/*
// @grant none
// ==/UserScript==
(function(){
const pad=10,k='miniPlayerPos',sK='miniPlayerSize',dK='miniPlayerDraggable';
const platform=location.hostname.includes('kick.com')?'kick':'twitch';
let header=null,spinner=null,miniPlayer=null,gearBtn=null,menu=null;
const link=document.createElement('link');
link.href='https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
link.rel='stylesheet';
document.head.appendChild(link);
function getHeaderHeight(){
const sel=['.top-nav','[data-a-target="top-nav"]','.top-nav__menu','header','.tw-header'];
for(let s of sel){let e=document.querySelector(s);if(e){let r=e.getBoundingClientRect();if(r.height>0)return r.bottom+pad;}}
return 80+pad;
}
function styleHeader(el){
Object.assign(el.style,{display:'flex',alignItems:'center',justifyContent:'center',color:'#fff',fontWeight:'600',fontSize:'15px',cursor:'pointer',gap:'6px'});
}
function createRedDot(){
const d=document.createElement('span');
d.id='latency-red-dot';
Object.assign(d.style,{display:'inline-block',width:'8px',height:'8px',borderRadius:'50%',background:'#FF4B4B'});
return d;
}
async function readTwitchStats(timeoutMs=1500){
const labels = ['Задержка до владельца канала', 'Latency To Broadcaster'];
let existing = null;
for (let label of labels){
existing = document.querySelector(`p[aria-label="${label}"]`);
if(existing) break;
}
if(existing) return existing.textContent.trim();
const toggle=()=>{
['keydown','keyup'].forEach(t=>document.dispatchEvent(
new KeyboardEvent(t,{ctrlKey:true,altKey:true,shiftKey:true,code:'KeyS',key:'S',bubbles:true,cancelable:true})
));
};
try{toggle();}catch{}
const start=Date.now();
while(Date.now()-start<timeoutMs){
for (let label of labels){
let p=document.querySelector(`p[aria-label="${label}"]`);
if(p && p.textContent.trim().length){
let c = p.closest('table,.tw-stat,div');
if(c) c.style.display='none';
try{toggle();}catch{}
return p.textContent.trim();
}
}
await new Promise(r=>setTimeout(r,150));
}
try{toggle();}catch{}
return null;
}
function readKickLatency(){
const v=document.querySelector('video');if(!v||!v.buffered.length)return null;
const lat=v.buffered.end(v.buffered.length-1)-v.currentTime;
return lat>0?lat.toFixed(2)+'s':'0.00s';
}
async function getLatency(){
if(platform==='kick')return readKickLatency();
let val=await readTwitchStats();
if(!val){const v=document.querySelector('video');if(v&&v.buffered.length){let l=v.buffered.end(v.buffered.length-1)-v.currentTime;return l>0?l.toFixed(2)+'s':'0.00s';}return null;}
const m=val.match(/([\d,.]+)\s*(сек|s|ms)?/i);
if(m&&m[1]){let num=parseFloat(m[1].replace(',','.'));if(m[2]&&/ms/i.test(m[2]))num/=1e3;return num.toFixed(2)+'s';}
return val;
}
async function updateHeader(){
if(!header)return;
const lat=await getLatency();
if(!lat)return;
header.innerHTML='';
let dot=document.getElementById('latency-red-dot');if(!dot)dot=createRedDot();
header.appendChild(dot);
const s=document.createElement('span');s.textContent=`Latency: ${lat}`;
header.appendChild(s);
}
function createSpinner(){
if(spinner)return spinner;
spinner=document.createElement('div');
spinner.id='latency-spinner';
Object.assign(spinner.style,{position:'absolute',top:'50%',left:'50%',transform:'translate(-50%,-50%)',zIndex:'9999',display:'none'});
const v=document.querySelector('video');if(v&&v.parentElement)v.parentElement.appendChild(spinner);
return spinner;
}
function reloadPlayer(){
const v=document.querySelector('video');if(!v)return;
const sp=createSpinner();sp.style.display='block';
const ct=v.currentTime;v.pause();
setTimeout(()=>{try{v.currentTime=ct;v.play().catch(()=>{});}catch{location.reload();}sp.style.display='none';updateHeader();},1200);
}
function findHeader(){
let candidate;
if(platform==='twitch'){
candidate = document.querySelector('#chat-room-header-label');
} else {
candidate = Array.from(document.querySelectorAll('span.absolute')).find(e => {
const text = e.textContent.trim().toLowerCase();
return text === 'чат' || text === 'chat';
});
}
if(candidate && candidate !== header){
header = candidate;
styleHeader(header);
header.addEventListener('click', reloadPlayer);
updateHeader();
}
}
const obs=new MutationObserver(findHeader);
obs.observe(document.body,{childList:true,subtree:true});
setInterval(()=>{if(header)updateHeader();},2000);
function initializeMiniPlayer(){
miniPlayer = document.querySelector('.persistent-player__border--mini');
if(!miniPlayer) return false;
if(!miniPlayer._originalWidth){
const currentScale = parseFloat(localStorage.getItem(sK)) || 1;
miniPlayer._originalWidth = miniPlayer.offsetWidth / currentScale;
miniPlayer._originalHeight = miniPlayer.offsetHeight / currentScale;
}
let saved = localStorage.getItem(k);
if(saved){
try{
let pos = JSON.parse(saved);
miniPlayer.style.left = pos.left + 'px';
miniPlayer.style.top = pos.top + 'px';
}catch{
miniPlayer.style.left = pad + 'px';
miniPlayer.style.top = getHeaderHeight() + 'px';
}
} else {
miniPlayer.style.left = pad + 'px';
miniPlayer.style.top = getHeaderHeight() + 'px';
}
let savedSize = parseFloat(localStorage.getItem(sK)) || 1;
miniPlayer.style.transform = `scale(${savedSize})`;
if(miniPlayer._dragInitialized) return true;
Object.assign(miniPlayer.style, {
position: 'fixed',
cursor: 'move',
zIndex: '9999',
margin: '0',
transition: 'transform 0.2s ease',
transformOrigin: 'top left'
});
addGearToMiniPlayer();
let dragging=false, startX=0, startY=0, initLeft=0, initTop=0;
miniPlayer.addEventListener('mousedown', e => {
if(!miniPlayer.classList.contains('persistent-player__border--mini')) return;
const draggable = localStorage.getItem(dK) !== 'false';
if(!draggable || e.target.closest('.mini-gear')) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
initLeft = parseFloat(miniPlayer.style.left);
initTop = parseFloat(miniPlayer.style.top);
miniPlayer.style.transition = 'none';
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
e.stopPropagation();
});
function drag(e) {
if (!dragging) return;
let dx = e.clientX - startX;
let dy = e.clientY - startY;
const currentScale = parseFloat(localStorage.getItem(sK)) || 1;
const rect = miniPlayer.getBoundingClientRect();
const scaledWidth = rect.width;
const scaledHeight = rect.height;
const newLeft = initLeft + dx;
const newTop = initTop + dy;
const minLeft = pad;
const minTop = getHeaderHeight();
const maxLeft = window.innerWidth - scaledWidth - pad;
const maxTop = window.innerHeight - scaledHeight - pad;
miniPlayer.style.left = Math.min(Math.max(newLeft, minLeft), maxLeft) + 'px';
miniPlayer.style.top = Math.min(Math.max(newTop, minTop), maxTop) + 'px';
e.preventDefault();
}
function stopDrag(){
if(!dragging) return;
dragging = false;
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
localStorage.setItem(k, JSON.stringify({
left: Math.round(parseFloat(miniPlayer.style.left)),
top: Math.round(parseFloat(miniPlayer.style.top))
}));
miniPlayer.style.transition = 'transform 0.2s ease';
}
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const currentScale = parseFloat(localStorage.getItem(sK)) || 1;
const scaledWidth = miniPlayer._originalWidth * currentScale;
const scaledHeight = miniPlayer._originalHeight * currentScale;
let left = parseFloat(miniPlayer.style.left);
let top = parseFloat(miniPlayer.style.top);
let newL = Math.max(pad, Math.min(left, window.innerWidth - scaledWidth - pad));
let newT = Math.max(getHeaderHeight(), Math.min(top, window.innerHeight - scaledHeight - pad));
if(newL !== left || newT !== top){
miniPlayer.style.left = newL + 'px';
miniPlayer.style.top = newT + 'px';
localStorage.setItem(k, JSON.stringify({left: Math.round(newL), top: Math.round(newT)}));
}
}, 100);
});
miniPlayer._dragInitialized = true;
return true;
}
function addGearToMiniPlayer(){
if(!miniPlayer||!miniPlayer.classList.contains('persistent-player__border--mini')) return;
document.querySelectorAll('.mini-gear').forEach(e=>e.remove());
gearBtn=document.createElement('button');
gearBtn.className='mini-gear';
Object.assign(gearBtn.style,{position:'absolute',top:'8px',right:'40px',background:'rgba(0,0,0,0.5)',border:'none',borderRadius:'4px',width:'24px',height:'24px',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',zIndex:'99999',opacity:'0',transition:'opacity 0.15s ease-in-out'});
gearBtn.innerHTML=`<svg width="16" height="16" viewBox="0 0 20 20" fill="#fff"><path d="M10 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"></path><path fill-rule="evenodd" d="M9 2h2a2.01 2.01 0 0 0 1.235 1.855l.53.22a2.01 2.01 0 0 0 2.185-.439l1.414 1.414a2.01 2.01 0 0 0-.439 2.185l.22.53A2.01 2.01 0 0 0 18 9v2a2.01 2.01 0 0 0-1.855 1.235l-.22.53a2.01 2.01 0 0 0 .44 2.185l-1.415 1.414a2.01 2.01 0 0 0-2.184-.439l-.531.22A2.01 2.01 0 0 0 11 18H9a2.01 2.01 0 0 0-1.235-1.854l-.53-.22a2.009 2.009 0 0 0-2.185.438L3.636 14.95a2.009 2.009 0 0 0 .438-2.184l-.22-.531A2.01 2.01 0 0 0 2 11V9c.809 0 1.545-.487 1.854-1.235l.22-.53a2.009 2.009 0 0 0-.438-2.185L5.05 3.636a2.01 2.01 0 0 0 2.185.438l.53-.22A2.01 2.01 0 0 0 9 2zm-4 8 1.464 3.536L10 15l3.535-1.464L15 10l-1.465-3.536L10 5 6.464 6.464 5 10z" clip-rule="evenodd"></path></svg>`;
miniPlayer.appendChild(gearBtn);
miniPlayer.addEventListener('mouseenter',()=>{gearBtn.style.opacity='1';});
miniPlayer.addEventListener('mouseleave',()=>{gearBtn.style.opacity='0';});
gearBtn.addEventListener('click',toggleMenu);
}
function createMenu(){
if(menu) return;
menu=document.createElement('div');
Object.assign(menu.style,{
position:'fixed',top:'50%',left:'50%',transform:'translate(-50%,-50%) scale(0.9)',
background:'#18181b',color:'#fff',padding:'20px',borderRadius:'12px',zIndex:'10000',
display:'none',fontFamily:'Inter,Arial,sans-serif',minWidth:'300px',
boxShadow:'0 0 15px rgba(0,0,0,0.5)',transition:'transform 0.2s ease, opacity 0.2s ease',opacity:'0',cursor:'default'
});
menu.innerHTML=`<div id="menu-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;cursor:move;">
<h3 style="margin:0;font-size:16px;">Settings:</h3>
<span id="close-menu" style="cursor:pointer;font-size:18px;font-weight:bold;">✖</span>
</div>
<label style="display:block;margin-bottom:10px;font-size:14px;">MiniPlayer Size:
<input type="range" id="miniplayer-size" min="1" max="2" step="0.05" value="1" style="width:100%;margin-top:5px;">
<span id="size-value" style="margin-left:6px;">100%</span>
</label>
<label style="display:block;margin-bottom:10px;font-size:14px;">Enable MiniPlayer Drag:
<input type="checkbox" id="drag-toggle" style="margin-left:6px;">
</label>
<div style="text-align:right;font-size:12px;margin-top:10px;font-style:italic;">Created by: frz</div>`;
document.body.appendChild(menu);
document.getElementById('close-menu').addEventListener('click',()=>{menu.style.opacity='0';menu.style.transform='translate(-50%,-50%) scale(0.9)';setTimeout(()=>{menu.style.display='none';},200);});
const slider=document.getElementById('miniplayer-size');
const display=document.getElementById('size-value');
const savedSize=parseFloat(localStorage.getItem(sK))||1;
slider.value=savedSize;
display.textContent=`${Math.round(savedSize*100)}%`;
slider.style.accentColor = '#9147ff';
let scaleTimeout;
slider.addEventListener('input',e=>{
const scale=parseFloat(e.target.value);
display.textContent=`${Math.round(scale*100)}%`;
clearTimeout(scaleTimeout);
scaleTimeout=setTimeout(()=>{
if(miniPlayer){
const r=miniPlayer.getBoundingClientRect();
localStorage.setItem(k,JSON.stringify({left:Math.round(r.left),top:Math.round(r.top)}));
miniPlayer.style.transform=`scale(${scale})`;
localStorage.setItem(sK,scale);
}
},150);
});
const dragToggle=document.getElementById('drag-toggle');
const dragSaved=localStorage.getItem(dK);
dragToggle.checked=dragSaved===null?true:dragSaved==='true';
dragToggle.style.accentColor = '#9147ff';
dragToggle.addEventListener('change',()=>{localStorage.setItem(dK,dragToggle.checked);});
let isMenuDragging=false,offsetX=0,offsetY=0;
const headerDrag=menu.querySelector('#menu-header');
headerDrag.addEventListener('mousedown',e=>{isMenuDragging=true;const rect=menu.getBoundingClientRect();offsetX=e.clientX-rect.left;offsetY=e.clientY-rect.top;menu.style.transition='none';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!isMenuDragging)return;let newX=e.clientX-offsetX;let newY=e.clientY-offsetY;newX=Math.max(0,Math.min(window.innerWidth-menu.offsetWidth,newX));newY=Math.max(0,Math.min(window.innerHeight-menu.offsetHeight,newY));menu.style.left=newX+'px';menu.style.top=newY+'px';menu.style.transform='translate(0,0)';});
document.addEventListener('mouseup',()=>{if(!isMenuDragging)return;isMenuDragging=false;menu.style.transition='transform 0.2s ease, opacity 0.2s ease';});
}
function toggleMenu(){createMenu();if(!menu)return;if(menu.style.display==='block'){menu.style.opacity='0';menu.style.transform='translate(-50%,-50%) scale(0.9)';setTimeout(()=>{menu.style.display='none';},200);}else{menu.style.display='block';menu.style.opacity='1';menu.style.transform='translate(-50%,-50%) scale(1)';}}
function checkPlayerState(){
const mini = document.querySelector('.persistent-player__border--mini');
const full = document.querySelector('.persistent-player:not(.persistent-player__border--mini)');
if (full) {
document.querySelectorAll('.mini-gear').forEach(e => e.remove());
if(menu && menu.style.display === 'block'){
menu.style.opacity='0';
menu.style.transform='translate(-50%,-50%) scale(0.9)';
setTimeout(()=>{menu.style.display='none';},200);
}
if (miniPlayer) {
miniPlayer._dragInitialized = false;
miniPlayer.style.cursor = 'default';
miniPlayer = null;
}
} else if (mini) {
miniPlayer = mini;
miniPlayer.style.cursor = 'move';
if (!mini.querySelector('.mini-gear')) {
addGearToMiniPlayer();
initializeMiniPlayer();
}
}
}
const playerObserver=new MutationObserver(()=>{checkPlayerState();});
playerObserver.observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['class']});
setInterval(checkPlayerState,1000);
setTimeout(checkPlayerState,1000);
})();