Skip to content

Instantly share code, notes, and snippets.

@kor-bim
Last active February 25, 2026 17:14
Show Gist options
  • Select an option

  • Save kor-bim/a9ec68cdce4ea56a2be4d176e01d7b9e to your computer and use it in GitHub Desktop.

Select an option

Save kor-bim/a9ec68cdce4ea56a2be4d176e01d7b9e to your computer and use it in GitHub Desktop.
YouTube - Jump to Most Replayed Button
// ==UserScript==
// @name YouTube 가장 많이 다시 본 장면 이동 버튼
// @name:el YouTube Μετάβαση στο πιο επαναλαμβανόμενο σημείο
// @name:nl YouTube Ga naar meest herhaalde segment
// @name:nb YouTube Gå til mest gjenspilte segment
// @name:da YouTube Gå til mest genspillede segment
// @name:de YouTube Zum meistgesehenen Abschnitt springen
// @name:ru YouTube Переход к самому просматриваемому фрагменту
// @name:ro YouTube Salt la segmentul cel mai reluat
// @name:mr YouTube सर्वाधिक पुन्हा पाहिलेल्या भागावर जा
// @name:vi YouTube Chuyển đến đoạn được xem lại nhiều nhất
// @name:be YouTube Пераход да найбольш прагляданага фрагмента
// @name:bg YouTube Преход към най-гледания сегмент
// @name:sr YouTube Прелазак на најгледанији сегмент
// @name:sv YouTube Hoppa till mest sedda segment
// @name:es YouTube Ir al segmento más repetido
// @name:es-419 YouTube Ir al segmento más repetido
// @name:sk YouTube Prejsť na najviac prehrávaný segment
// @name:ar YouTube الانتقال إلى الجزء الأكثر إعادة
// @name:eo YouTube Salti al plej reludita segmento
// @name:en YouTube Jump to Most Replayed Button
// @name:uk YouTube Перехід до найчастіше переглядуваного фрагмента
// @name:ug YouTube ئەڭ كۆپ قايتا كۆرۈلگەن بۆلەككە ئۆتۈش
// @name:it YouTube Vai al segmento più riprodotto
// @name:id YouTube Lompat ke segmen paling sering diputar
// @name:ja YouTube 最も再生されたシーンへ移動
// @name:ka YouTube ყველაზე ხშირად ნანახ ნაწილზე გადასვლა
// @name:zh-CN YouTube 跳转到最常重播片段
// @name:zh-TW YouTube 跳至最常重播片段
// @name:cs YouTube Přejít na nejčastěji přehrávaný segment
// @name:hr YouTube Prijeđi na najgledaniji segment
// @name:th YouTube ไปยังช่วงที่ถูกเล่นซ้ำมากที่สุด
// @name:tr YouTube En Çok Tekrar İzlenen Bölüme Git
// @name:pt-BR YouTube Ir para o trecho mais reproduzido
// @name:pl YouTube Przejdź do najczęściej odtwarzanego segmentu
// @name:fr YouTube Aller au segment le plus rejoué
// @name:fr-CA YouTube Aller au segment le plus rejoué
// @name:fi YouTube Siirry eniten toistettuun kohtaan
// @name:ko YouTube 가장 많이 다시 본 장면 이동 버튼
// @name:hu YouTube Ugrás a leggyakrabban visszajátszott részhez
// @name:he YouTube מעבר לקטע הנצפה ביותר
// @name:ckb YouTube بڕۆ بۆ زۆرترین بەشی دووبارەبینراو
// @description 영상에서 가장 많이 다시 본 장면으로 이동하는 버튼을 추가합니다
// @description:el Προσθέτει ένα κουμπί που μεταβαίνει στο πιο επαναλαμβανόμενο σημείο του βίντεο
// @description:nl Voegt een knop toe om naar het meest herhaalde segment van de video te springen
// @description:nb Legger til en knapp som går til det mest gjenspilte segmentet i videoen
// @description:da Tilføjer en knap der hopper til det mest genspillede segment i videoen
// @description:de Fügt eine Schaltfläche hinzu um zum meistgesehenen Abschnitt des Videos zu springen
// @description:ru Добавляет кнопку для перехода к самому просматриваемому фрагменту видео
// @description:ro Adaugă un buton pentru a sări la segmentul cel mai reluat al videoclipului
// @description:mr व्हिडिओमधील सर्वाधिक पुन्हा पाहिलेल्या भागावर जाण्यासाठी बटण जोडते
// @description:vi Thêm nút để chuyển đến đoạn được xem lại nhiều nhất trong video
// @description:be Дадае кнопку для пераходу да найбольш прагляданага фрагмента відэа
// @description:bg Добавя бутон за преминаване към най-гледания сегмент на видеото
// @description:sr Додаје дугме за прелазак на најгледанији сегмент видеа
// @description:sv Lägger till en knapp för att hoppa till det mest sedda segmentet i videon
// @description:es Añade un botón para saltar al segmento más reproducido del video
// @description:es-419 Agrega un botón para saltar al segmento más reproducido del video
// @description:sk Pridáva tlačidlo na preskočenie na najviac prehrávaný segment videa
// @description:ar يضيف زرًا للانتقال إلى الجزء الأكثر إعادة في الفيديو
// @description:eo Aldonas butonon por salti al plej reludita segmento de la video
// @description:en Adds a button that jumps to the most replayed segment of the video
// @description:uk Додає кнопку для переходу до найчастіше переглядуваного фрагмента відео
// @description:ug سىننىڭ ئەڭ كۆپ قايتا كۆرۈلگەن بۆلىكىگە ئۆتۈش كۇنۇپكىسىنى قوشىدۇ
// @description:it Aggiunge un pulsante per saltare al segmento più riprodotto del video
// @description:id Menambahkan tombol untuk melompat ke segmen yang paling sering diputar dalam video
// @description:ja 動画内で最も再生されたシーンへ移動するボタンを追加します
// @description:ka ამატებს ღილაკს ვიდეოში ყველაზე ხშირად ნანახ ნაწილზე გადასასვლელად
// @description:zh-CN 添加一个按钮可跳转到视频中最常被重播的片段
// @description:zh-TW 新增一個按鈕可跳至影片中最常被重播的片段
// @description:cs Přidá tlačítko pro přechod na nejčastěji přehrávaný segment videa
// @description:hr Dodaje gumb za prelazak na najgledaniji segment videozapisa
// @description:th เพิ่มปุ่มสำหรับไปยังช่วงของวิดีโอที่ถูกเล่นซ้ำมากที่สุด
// @description:tr Videodaki en çok tekrar izlenen bölüme gitmek için bir düğme ekler
// @description:pt-BR Adiciona um botão para pular para o trecho mais reproduzido do vídeo
// @description:pl Dodaje przycisk do przejścia do najczęściej odtwarzanego segmentu wideo
// @description:fr Ajoute un bouton pour accéder au segment le plus rejoué de la vidéo
// @description:fr-CA Ajoute un bouton pour accéder au segment le plus rejoué de la vidéo
// @description:fi Lisää painikkeen siirtymiseen videon eniten toistettuun kohtaan
// @description:ko 영상에서 가장 많이 다시 본 장면으로 이동하는 버튼을 추가합니다
// @description:hu Hozzáad egy gombot a videó leggyakrabban visszajátszott részére ugráshoz
// @description:he מוסיף כפתור למעבר לקטע הנצפה ביותר בסרטון
// @description:ckb دوگمەیەک زیاد دەکات بۆ چوون بۆ زۆرترین بەشی دووبارەبینراوی ڤیدیۆ
// @author kor-bim
// @namespace http://tampermonkey.net/
// @version 2.0.2
// @match https://www.youtube.com/*
// @icon https://www.youtube.com/s/desktop/aaaab8bf/img/favicon_144x144.png
// @run-at document-start
// @grant none
// @license MIT
// ==/UserScript==
(() => {
'use strict';
// Expose tiny debug helpers on the page window
const PAGE = (() => {
try {
if (typeof unsafeWindow !== 'undefined') return unsafeWindow;
} catch {}
return window;
})();
PAGE.__GF_MRJ = PAGE.__GF_MRJ || {};
PAGE.__GF_MRJ.enableDebug = () => {
PAGE.__GF_MRJ_DEBUG = true;
console.log('[MostReplayedJump] debug enabled');
};
PAGE.__GF_MRJ.disableDebug = () => {
PAGE.__GF_MRJ_DEBUG = false;
console.log('[MostReplayedJump] debug disabled');
};
PAGE.__GF_MRJ.inspectYID = () => {
const w = PAGE;
const best = w.__GF_YID_BEST;
const latest = w.__GF_YID_LATEST;
const bestLen = Array.isArray(best?.frameworkUpdates?.entityBatchUpdate?.mutations)
? best.frameworkUpdates.entityBatchUpdate.mutations.length
: null;
const latestLen = Array.isArray(latest?.frameworkUpdates?.entityBatchUpdate?.mutations)
? latest.frameworkUpdates.entityBatchUpdate.mutations.length
: null;
console.log('[MostReplayedJump] yid snapshot', { bestLen, latestLen, hasBest: !!best, hasLatest: !!latest });
return { best, latest };
};
class MostReplayedJump {
static CONFIG = Object.freeze({
btnId: 'gf-mostreplayed-jump',
peakOffsetMs: 0,
// Set to true, or run in console: `unsafeWindow.__GF_MRJ_DEBUG = true` (or `window.__GF_MRJ_DEBUG = true`)
debug: false,
wait: Object.freeze({
maxMs: 8000,
pollMs: 200,
}),
seg: Object.freeze({
dedupMs: 500,
nextThresholdMs: 250,
}),
ui: Object.freeze({
fallbackEnsureMs: 1500,
toast: Object.freeze({
id: 'gf-mostreplayed-toast',
removeAfterMs: 900,
cssText: `
position:absolute;
left:50%;
bottom:72px;
transform:translateX(-50%);
background:rgba(0,0,0,.78);
color:#fff;
padding:6px 10px;
border-radius:10px;
font-size:12px;
z-index:999999;
pointer-events:none;
white-space:nowrap;
line-height:1;
`,
}),
}),
i18n: Object.freeze({
// 'auto' | 'ko' | 'en'
lang: 'auto',
dict: Object.freeze({
ko: Object.freeze({
btnTitle: '가장 많이 다시 본 장면으로 이동',
btnAria: '가장 많이 다시 본 장면으로 이동',
toastSearching: '찾는 중…',
toastNotFound: '가장 많이 다시 본 장면 없음',
toastAccessDenied: 'Data 접근 불가',
}),
en: Object.freeze({
btnTitle: 'Jump to Most Replayed',
btnAria: 'Jump to Most Replayed',
toastSearching: 'Searching…',
toastNotFound: 'Most Replayed Not Found',
toastAccessDenied: 'Cannot access Data',
}),
}),
}),
});
#cache = {
parsedByVid: new Map(), // videoId -> segments[]
inflightByVid: new Map(), // videoId -> Promise<boolean>
fetchedByVid: new Set(), // videoId (avoid spamming next)
};
#ytcfgGet(key) {
try {
const w = this.#win();
const ytcfg = w?.ytcfg;
if (ytcfg?.get) return ytcfg.get(key);
const data = ytcfg?.data_;
return data ? data[key] : undefined;
} catch {
return undefined;
}
}
async #fetchNextAndStore(videoId) {
if (!videoId) return false;
// Deduplicate
if (this.#cache.fetchedByVid.has(videoId)) return false;
if (this.#cache.inflightByVid.has(videoId)) return this.#cache.inflightByVid.get(videoId);
const p = (async () => {
try {
const apiKey = this.#ytcfgGet('INNERTUBE_API_KEY');
const ctx = this.#ytcfgGet('INNERTUBE_CONTEXT');
const clientName = this.#ytcfgGet('INNERTUBE_CLIENT_NAME');
const clientVersion = this.#ytcfgGet('INNERTUBE_CLIENT_VERSION');
const visitorData = this.#ytcfgGet('VISITOR_DATA');
if (!apiKey || !ctx) {
this.#log('next fetch skipped: missing apiKey/ctx', { hasKey: !!apiKey, hasCtx: !!ctx });
return false;
}
const url = `https://www.youtube.com/youtubei/v1/next?key=${encodeURIComponent(apiKey)}`;
const headers = {
'content-type': 'application/json',
};
if (clientName) headers['x-youtube-client-name'] = String(clientName);
if (clientVersion) headers['x-youtube-client-version'] = String(clientVersion);
if (visitorData) headers['x-goog-visitor-id'] = String(visitorData);
this.#log('next fetch start', { videoId });
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers,
body: JSON.stringify({
context: ctx,
videoId,
}),
});
if (!res.ok) {
this.#log('next fetch failed', { videoId, status: res.status });
return false;
}
const json = await res.json();
const segs = this.#extractSegments(json, videoId);
this.#log('next fetch ok', { videoId, segs: segs.length });
if (segs.length) {
this.#cache.parsedByVid.set(videoId, this.#mergePreferNew(this.#cache.parsedByVid.get(videoId), segs));
}
return segs.length > 0;
} catch (e) {
this.#log('next fetch error', e);
return false;
} finally {
this.#cache.fetchedByVid.add(videoId);
this.#cache.inflightByVid.delete(videoId);
}
})();
this.#cache.inflightByVid.set(videoId, p);
return p;
}
#timers = { fallbackEnsure: null };
#observer = null;
run() {
// Capture latest ytInitialData even if YouTube overwrites it during SPA init
this.#hookYtInitialData();
this.#log('run', { url: location.href, readyState: document.readyState });
console.log('[MostReplayedJump] loaded v1.1.5');
// Bind SPA navigation events early
this.#bindNavigation();
// UI/DOM-dependent boot should run only after DOM is ready
const startBoot = () => this.#boot();
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', startBoot, { once: true });
} else {
startBoot();
}
}
#hookYtInitialData() {
const w = this.#win();
if (!w || w.__GF_YID_HOOKED) return;
w.__GF_YID_HOOKED = true;
const getMutLen = (obj) => {
const m = obj?.frameworkUpdates?.entityBatchUpdate?.mutations;
return Array.isArray(m) ? m.length : 0;
};
let latest = w.ytInitialData;
let best = latest;
// Initialize best with current value
w.__GF_YID_LATEST = latest;
w.__GF_YID_BEST = best;
Object.defineProperty(w, 'ytInitialData', {
configurable: true,
get() {
return latest;
},
set(v) {
latest = v;
w.__GF_YID_LATEST = v;
// Keep whichever has richer frameworkUpdates (mutations length).
// YouTube sometimes overwrites ytInitialData with a slimmer object later.
const curBestLen = getMutLen(best);
const newLen = getMutLen(v);
if (newLen > curBestLen) {
best = v;
w.__GF_YID_BEST = v;
}
// Optional debug
if (w.__GF_MRJ_DEBUG || window.__GF_MRJ_DEBUG) {
console.log('[MostReplayedJump] ytInitialData set', {
newMutationsLen: newLen,
bestMutationsLen: getMutLen(best),
});
}
},
});
// Also store whatever was present at hook time
w.__GF_YID_LATEST = latest;
w.__GF_YID_BEST = best;
// Allow toggling from page console: window.__GF_MRJ_DEBUG = true
if (w.__GF_MRJ_DEBUG || window.__GF_MRJ_DEBUG) console.log('[MostReplayedJump] ytInitialData hook installed');
}
/* ------------------------------- i18n ------------------------------- */
#lang() {
const htmlLang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
if (htmlLang.startsWith('ko')) return 'ko';
const navLang = (navigator.language || '').toLowerCase();
if (navLang.startsWith('ko')) return 'ko';
return 'en';
}
#t(key) {
const { dict } = MostReplayedJump.CONFIG.i18n;
const lang = this.#lang();
return dict[lang]?.[key] ?? dict.en?.[key] ?? String(key);
}
/* ----------------------------- Env helpers ----------------------------- */
#win() {
try {
if (typeof unsafeWindow !== 'undefined') return unsafeWindow;
} catch {}
return window;
}
#isWatch() {
return location.pathname.startsWith('/watch');
}
#qs(sel, root = document) {
return root.querySelector(sel);
}
#player() {
return document.getElementById('movie_player');
}
#video() {
return document.querySelector('video.html5-main-video') || document.querySelector('video');
}
#videoId() {
const p = this.#player();
const id = p?.getVideoData?.()?.video_id;
if (id) return id;
const u = new URL(location.href);
return u.searchParams.get('v') || null;
}
#sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/* --------------------------------- UI --------------------------------- */
#toast(text) {
const player = this.#player();
if (!player) return;
const { id, removeAfterMs, cssText } = MostReplayedJump.CONFIG.ui.toast;
document.getElementById(id)?.remove();
const el = document.createElement('div');
el.id = id;
el.textContent = text;
el.style.cssText = cssText;
player.appendChild(el);
// Keep the "searching" toast visible a bit longer; others auto-remove quickly.
const keepMs = text === this.#t('toastSearching') ? Math.max(removeAfterMs, 2500) : removeAfterMs;
setTimeout(() => el.remove(), keepMs);
}
#makeIconSvg() {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
const isOldUI = !document.querySelector('.ytp-right-controls-left');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
if (isOldUI) {
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
} else {
svg.setAttribute('width', '24');
svg.setAttribute('height', '24');
}
// 아이콘 그룹
const g = document.createElementNS(svgNS, 'g');
// 옛 UI에서만 크기 보정
if (isOldUI) {
const scale = 0.75;
const offset = (24 * (1 - scale)) / 2;
g.setAttribute(
'transform',
`translate(${offset} ${offset}) scale(${scale})`
);
}
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M4 16l6-6 4 4 6-8');
path.setAttribute('stroke', 'white');
path.setAttribute('stroke-width', '2');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
const dot = (cx, cy) => {
const c = document.createElementNS(svgNS, 'circle');
c.setAttribute('cx', cx);
c.setAttribute('cy', cy);
c.setAttribute('r', '1.6');
c.setAttribute('fill', 'white');
return c;
};
g.appendChild(path);
g.appendChild(dot(4, 16));
g.appendChild(dot(10, 10));
g.appendChild(dot(14, 14));
g.appendChild(dot(20, 6));
svg.appendChild(g);
return svg;
}
#ensureButton() {
// 1) 기존(신 UI에서 존재할 수 있음)
const preferred =
this.#qs('.ytp-right-controls .ytp-right-controls-left') ||
// 2) 구 UI: right-controls에 바로 버튼들이 있음
this.#qs('.ytp-right-controls') ||
// 3) 최후: 전체 컨트롤 영역
this.#qs('.ytp-chrome-controls .ytp-right-controls') ||
this.#qs('.ytp-chrome-controls');
if (!preferred) return false;
if (document.getElementById(MostReplayedJump.CONFIG.btnId)) return true;
const btn = document.createElement('button');
btn.id = MostReplayedJump.CONFIG.btnId;
btn.className = 'ytp-button';
btn.type = 'button';
btn.title = this.#t('btnTitle');
btn.setAttribute('aria-label', this.#t('btnAria'));
btn.appendChild(this.#makeIconSvg());
btn.addEventListener('click', (e) => this.#onClick(e), true);
// 구 UI에서 우측 버튼들 사이 “자연스러운 위치”에 끼워넣기(전체화면 버튼 앞)
const fullscreen = preferred.querySelector('.ytp-fullscreen-button');
if (fullscreen && fullscreen.parentElement === preferred) {
preferred.insertBefore(btn, fullscreen);
} else {
preferred.appendChild(btn);
}
return true;
}
#removeButton() {
document.getElementById(MostReplayedJump.CONFIG.btnId)?.remove();
}
/* ------------------------------- Parsing ------------------------------- */
#log(...args) {
// NOTE: Use console.log so it shows up even when DevTools filters out "Verbose"/"Debug".
const w = this.#win();
const enabled =
MostReplayedJump.CONFIG.debug ||
w?.__GF_MRJ_DEBUG ||
window?.__GF_MRJ_DEBUG;
if (enabled) console.log('[MostReplayedJump]', ...args);
}
#isMostReplayedDecoration(dec) {
// Language-agnostic detection:
// If decorationTimeMillis exists, this is a heatmap peak (Most Replayed).
const decoMs = Number(dec?.decorationTimeMillis);
return Number.isFinite(decoMs);
}
#scoreFromBins(bins, decoMs) {
// bins: [{ startMillis: "0", durationMillis: "4930", intensityScoreNormalized: 0.24 }, ...]
if (!Array.isArray(bins) || !Number.isFinite(decoMs)) return null;
for (const b of bins) {
const s = Number(b?.startMillis);
const d = Number(b?.durationMillis);
if (!Number.isFinite(s) || !Number.isFinite(d) || d <= 0) continue;
if (decoMs >= s && decoMs < s + d) {
const sc = Number(b?.intensityScoreNormalized);
return Number.isFinite(sc) ? sc : null;
}
}
return null;
}
#extractSegments(root, currentVid) {
// 목표 구조: frameworkUpdates.entityBatchUpdate.mutations[].payload.macroMarkersListEntity
const out = [];
const muts = root?.frameworkUpdates?.entityBatchUpdate?.mutations;
if (!Array.isArray(muts)) return out;
for (const mut of muts) {
const ent = mut?.payload?.macroMarkersListEntity;
if (!ent) continue;
const extVid = ent?.externalVideoId || null;
if (currentVid && extVid && extVid !== currentVid) continue;
const list = ent?.markersList;
const timed = list?.markersDecoration?.timedMarkerDecorations;
if (!Array.isArray(timed) || timed.length === 0) continue;
const bins = list?.markers;
for (const d of timed) {
if (!this.#isMostReplayedDecoration(d)) continue;
const startMs = Number(d?.visibleTimeRangeStartMillis);
const endMs = Number(d?.visibleTimeRangeEndMillis);
const decoMs = Number(d?.decorationTimeMillis);
if (!Number.isFinite(decoMs)) continue;
const s = Number.isFinite(startMs) ? startMs : null;
const e = Number.isFinite(endMs) ? endMs : null;
const jumpMs = decoMs + MostReplayedJump.CONFIG.peakOffsetMs;
const score = this.#scoreFromBins(bins, decoMs);
out.push({ startMs: s, endMs: e, decoMs, jumpMs, score, videoId: extVid });
}
}
out.sort((a, b) => (a.jumpMs || 0) - (b.jumpMs || 0));
return out;
}
#mergePreferNew(a, b) {
const all = [...(a || []), ...(b || [])].filter(Boolean);
// jumpMs 기준 정렬 후 dedup(가까우면 score 높은 걸 유지)
all.sort((x, y) => (x.jumpMs || 0) - (y.jumpMs || 0));
const out = [];
const { dedupMs } = MostReplayedJump.CONFIG.seg;
for (const s of all) {
const jm = s?.jumpMs;
if (!Number.isFinite(jm)) continue;
const last = out[out.length - 1];
if (!last) {
out.push(s);
continue;
}
if (Math.abs(jm - last.jumpMs) <= dedupMs) {
const aSc = Number(last.score);
const bSc = Number(s.score);
if (Number.isFinite(bSc) && (!Number.isFinite(aSc) || bSc > aSc)) out[out.length - 1] = s;
} else {
out.push(s);
}
}
return out;
}
#refreshParsedCache() {
const vid = this.#videoId();
if (!vid) return;
const w = this.#win();
const yid = w?.__GF_YID_BEST || w?.__GF_YID_LATEST || w?.ytInitialData;
if (!yid) return;
const segs = this.#extractSegments(yid, vid);
if (!segs.length) return;
this.#cache.parsedByVid.set(vid, this.#mergePreferNew(this.#cache.parsedByVid.get(vid), segs));
}
#segmentsForCurrentVid() {
const vid = this.#videoId();
if (!vid) return [];
return this.#cache.parsedByVid.get(vid) || [];
}
/* ------------------------------- Jumping ------------------------------- */
#pickNext(segs, curMs) {
const { nextThresholdMs } = MostReplayedJump.CONFIG.seg;
const inSeg = segs
.filter((s) => s.startMs != null && s.endMs != null && curMs >= s.startMs && curMs <= s.endMs)
.sort((a, b) => (Number(b.score) || -1) - (Number(a.score) || -1))[0];
const base = inSeg?.endMs ?? curMs;
const next = segs.find((s) => s.jumpMs > base + nextThresholdMs);
return next || segs[0] || null;
}
#seekToMs(ms) {
const player = this.#player();
const video = this.#video();
if (!player || !video) return false;
const sec = ms / 1000;
try {
player.seekTo(sec, true);
player.playVideo?.();
return true;
} catch {
if (typeof video.fastSeek === 'function') video.fastSeek(sec);
else video.currentTime = sec;
video.play?.().catch(() => {});
return true;
}
}
async #getSegmentsOnClickWait() {
const start = Date.now();
const { maxMs, pollMs } = MostReplayedJump.CONFIG.wait;
const vid = this.#videoId();
if (!vid) return [];
while (Date.now() - start < maxMs) {
// 1) try local ytInitialData snapshot
this.#refreshParsedCache();
let segs = this.#segmentsForCurrentVid();
if (segs.length) return segs;
// 2) fallback: fetch youtubei next (once)
await this.#fetchNextAndStore(vid);
segs = this.#segmentsForCurrentVid();
if (segs.length) return segs;
await this.#sleep(pollMs);
}
return [];
}
/* ------------------------------ Lifecycle ------------------------------ */
#startObserver() {
this.#observer?.disconnect();
this.#observer = new MutationObserver(() => {
if (!this.#isWatch()) return;
this.#ensureButton();
});
this.#observer.observe(document.documentElement, { childList: true, subtree: true });
}
#startFallbackEnsure() {
this.#stopFallbackEnsure();
this.#timers.fallbackEnsure = setInterval(() => {
if (!this.#isWatch()) return;
this.#ensureButton();
}, MostReplayedJump.CONFIG.ui.fallbackEnsureMs);
}
#stopFallbackEnsure() {
if (this.#timers.fallbackEnsure) clearInterval(this.#timers.fallbackEnsure);
this.#timers.fallbackEnsure = null;
}
#boot() {
if (!this.#isWatch()) {
this.#removeButton();
this.#stopFallbackEnsure();
return;
}
this.#ensureButton();
this.#refreshParsedCache();
try {
const w = this.#win();
const yid = w?.__GF_YID_LATEST || w?.ytInitialData;
const mlen = yid?.frameworkUpdates?.entityBatchUpdate?.mutations?.length ?? null;
this.#log('boot', { vid: this.#videoId(), mutationsLen: mlen });
} catch {}
this.#startObserver();
this.#startFallbackEnsure();
}
#bindNavigation() {
// SPA navigation: controls/ytInitialData may appear slightly after navigate-finish
window.addEventListener(
'yt-navigate-finish',
() => {
this.#boot();
// one extra delayed boot to catch late-mounted controls / data
setTimeout(() => this.#boot(), 300);
},
true
);
window.addEventListener('popstate', () => this.#boot(), true);
}
/* -------------------------------- Events ------------------------------ */
async #onClick(e) {
e.preventDefault();
e.stopPropagation();
try {
const video = this.#video();
if (!video) return;
if (!this.#isWatch()) return;
this.#toast(this.#t('toastSearching'));
const segs = await this.#getSegmentsOnClickWait();
this.#log('click resolved segs', { vid: this.#videoId(), count: segs.length, segs });
if (!segs.length) {
const win = this.#win();
this.#toast(win?.ytInitialData ? this.#t('toastNotFound') : this.#t('toastAccessDenied'));
return;
}
const curMs = video.currentTime * 1000;
const target = this.#pickNext(segs, curMs);
if (!target) return;
this.#seekToMs(target.jumpMs);
} catch (err) {
this.#log('onClick error', err);
this.#toast(this.#t('toastNotFound'));
}
}
}
new MostReplayedJump().run();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment