Twitch & Kick Latency

Displays Twitch & Kick latency with proper player reload and MiniPlayer

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
})();