Instantly share code, notes, and snippets.
Last active
February 25, 2026 17:14
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save kor-bim/a9ec68cdce4ea56a2be4d176e01d7b9e to your computer and use it in GitHub Desktop.
YouTube - Jump to Most Replayed Button
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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