Instantly share code, notes, and snippets.
Last active
February 5, 2026 23:21
-
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 1.1.5 | |
| // @match https://www.youtube.com/* | |
| // @icon https://www.youtube.com/s/desktop/aaaab8bf/img/favicon_144x144.png | |
| // @run-at document-idle | |
| // @grant unsafeWindow | |
| // @license MIT | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| class MostReplayedJump { | |
| static CONFIG = Object.freeze({ | |
| btnId: 'gf-mostreplayed-jump', | |
| peakOffsetMs: 0, | |
| 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; | |
| `, | |
| }), | |
| }), | |
| net: Object.freeze({ | |
| urlIncludes: ['/youtubei/v1/'], | |
| likelyEndpoints: ['/player', '/next', '/get_watch_next', '/browse'], | |
| }), | |
| 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 = { | |
| netByVid: new Map(), // videoId -> segments[] | |
| parsedByVid: new Map(), // videoId -> segments[] | |
| domTried: new Set(), // videoId | |
| }; | |
| #timers = { fallbackEnsure: null }; | |
| #observer = null; | |
| run() { | |
| this.#hookNetwork(); | |
| this.#bindNavigation(); | |
| this.#boot(); | |
| } | |
| /* ------------------------------- i18n ------------------------------- */ | |
| #lang() { | |
| const cfg = MostReplayedJump.CONFIG.i18n; | |
| if (cfg.lang === 'ko' || cfg.lang === 'en') return cfg.lang; | |
| const htmlLang = (document.documentElement.getAttribute('lang') || '').toLowerCase(); | |
| if (htmlLang.startsWith('ko')) return 'ko'; | |
| const navLang = (navigator.language || '').toLowerCase(); | |
| return navLang.startsWith('ko') ? 'ko' : '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); | |
| setTimeout(() => el.remove(), removeAfterMs); | |
| } | |
| #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 ------------------------------- */ | |
| #normalizeLabel(dec) { | |
| const t = dec?.label?.runs?.[0]?.text; | |
| return typeof t === 'string' ? t.trim() : ''; | |
| } | |
| // 언어 무관 판별: Most Replayed timed marker는 보통 (start/end + decorationTimeMillis) 조합을 가진다. | |
| #isMostReplayedDecoration(dec) { | |
| const startMs = Number(dec?.visibleTimeRangeStartMillis); | |
| const endMs = Number(dec?.visibleTimeRangeEndMillis); | |
| const decoMs = Number(dec?.decorationTimeMillis); | |
| if (!Number.isFinite(decoMs)) return false; | |
| // start/end가 둘 다 존재하는 경우를 우선 신뢰 | |
| if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) { | |
| // deco가 범위 밖인 경우도 있을 수 있어 너무 빡세게 제한하지 않음 | |
| return true; | |
| } | |
| // 일부 케이스에서 start/end가 비거나 구조가 다를 수 있으니 라벨 기반 보조(있으면) | |
| const label = this.#normalizeLabel(dec); | |
| if (!label) return false; | |
| const low = label.toLowerCase(); | |
| return low.includes('most') && low.includes('replay'); | |
| } | |
| #dedupByJumpMs(segs) { | |
| const out = []; | |
| let last = null; | |
| const { dedupMs } = MostReplayedJump.CONFIG.seg; | |
| for (const s of segs) { | |
| const jm = s?.jumpMs; | |
| if (!Number.isFinite(jm)) continue; | |
| if (last == null || Math.abs(jm - last) > dedupMs) { | |
| out.push(s); | |
| last = jm; | |
| } | |
| } | |
| return out; | |
| } | |
| #mergePreferNew(a, b) { | |
| const all = [...(a || []), ...(b || [])].filter(Boolean); | |
| all.sort((x, y) => (x.jumpMs || 0) - (y.jumpMs || 0)); | |
| return this.#dedupByJumpMs(all); | |
| } | |
| #extractSegmentsFromRoot(root, currentVid) { | |
| const muts = root?.frameworkUpdates?.entityBatchUpdate?.mutations; | |
| if (!Array.isArray(muts)) return []; | |
| const out = []; | |
| for (const mut of muts) { | |
| const ent = mut?.payload?.macroMarkersListEntity; | |
| if (!ent) continue; | |
| const extVid = ent?.externalVideoId; | |
| if (currentVid && extVid && extVid !== currentVid) continue; | |
| const timed = ent?.markersList?.markersDecoration?.timedMarkerDecorations; | |
| if (!Array.isArray(timed) || timed.length === 0) continue; | |
| 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; | |
| out.push({ startMs: s, endMs: e, decoMs, jumpMs, videoId: extVid || null }); | |
| } | |
| } | |
| out.sort((a, b) => a.jumpMs - b.jumpMs); | |
| return this.#dedupByJumpMs(out); | |
| } | |
| #extractVarObjectFromScriptText(text, varName) { | |
| const idx = text.indexOf(`var ${varName} =`); | |
| if (idx < 0) return null; | |
| const startBrace = text.indexOf('{', idx); | |
| if (startBrace < 0) return null; | |
| let depth = 0; | |
| let inStr = false; | |
| let strCh = ''; | |
| let esc = false; | |
| for (let i = startBrace; i < text.length; i++) { | |
| const ch = text[i]; | |
| if (inStr) { | |
| if (esc) esc = false; | |
| else if (ch === '\\') esc = true; | |
| else if (ch === strCh) { | |
| inStr = false; | |
| strCh = ''; | |
| } | |
| continue; | |
| } | |
| if (ch === '"' || ch === "'") { | |
| inStr = true; | |
| strCh = ch; | |
| continue; | |
| } | |
| if (ch === '{') depth++; | |
| else if (ch === '}') { | |
| depth--; | |
| if (depth === 0) return text.slice(startBrace, i + 1); | |
| } | |
| } | |
| return null; | |
| } | |
| #parseYtInitialDataFromDomScriptsOnce(vid) { | |
| if (!vid) return null; | |
| if (this.#cache.domTried.has(vid)) return null; | |
| this.#cache.domTried.add(vid); | |
| for (const s of document.querySelectorAll('script')) { | |
| const txt = s.textContent || ''; | |
| if (!txt.includes('var ytInitialData =')) continue; | |
| const objText = this.#extractVarObjectFromScriptText(txt, 'ytInitialData'); | |
| if (!objText) continue; | |
| try { | |
| return JSON.parse(objText); | |
| } catch {} | |
| } | |
| return null; | |
| } | |
| #refreshParsedCache() { | |
| const vid = this.#videoId(); | |
| if (!vid) return; | |
| const yid = this.#win()?.ytInitialData; | |
| if (yid) { | |
| const segs = this.#extractSegmentsFromRoot(yid, vid); | |
| if (segs.length) { | |
| this.#cache.parsedByVid.set(vid, this.#mergePreferNew(this.#cache.parsedByVid.get(vid), segs)); | |
| return; | |
| } | |
| } | |
| const parsed = this.#parseYtInitialDataFromDomScriptsOnce(vid); | |
| if (parsed) { | |
| const segs = this.#extractSegmentsFromRoot(parsed, vid); | |
| if (segs.length) { | |
| this.#cache.parsedByVid.set(vid, this.#mergePreferNew(this.#cache.parsedByVid.get(vid), segs)); | |
| } | |
| } | |
| } | |
| #segmentsForCurrentVid() { | |
| const vid = this.#videoId(); | |
| if (!vid) return []; | |
| return this.#mergePreferNew(this.#cache.netByVid.get(vid), this.#cache.parsedByVid.get(vid)); | |
| } | |
| /* ------------------------------- Jumping ------------------------------- */ | |
| #pickNext(segs, curMs) { | |
| const { nextThresholdMs } = MostReplayedJump.CONFIG.seg; | |
| const inSeg = segs.find( | |
| (s) => s.startMs != null && s.endMs != null && curMs >= s.startMs && curMs <= s.endMs | |
| ); | |
| 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; | |
| this.#refreshParsedCache(); | |
| let segs = this.#segmentsForCurrentVid(); | |
| if (segs.length) return segs; | |
| while (Date.now() - start < maxMs) { | |
| await this.#sleep(pollMs); | |
| this.#refreshParsedCache(); | |
| segs = this.#segmentsForCurrentVid(); | |
| if (segs.length) return segs; | |
| } | |
| return []; | |
| } | |
| /* ------------------------------- Network ------------------------------- */ | |
| #shouldParseYoutubeiUrl(url) { | |
| const { urlIncludes, likelyEndpoints } = MostReplayedJump.CONFIG.net; | |
| if (!url) return false; | |
| if (!urlIncludes.some((t) => url.includes(t))) return false; | |
| return likelyEndpoints.some((t) => url.includes(t)); | |
| } | |
| #storeFromResponseJson(json) { | |
| try { | |
| const currentVid = this.#videoId(); | |
| const segs = this.#extractSegmentsFromRoot(json, currentVid); | |
| if (!segs.length) return; | |
| const vid = segs.find((s) => s.videoId)?.videoId || currentVid; | |
| if (!vid) return; | |
| this.#cache.netByVid.set(vid, this.#mergePreferNew(this.#cache.netByVid.get(vid), segs)); | |
| } catch {} | |
| } | |
| #hookFetch() { | |
| const win = this.#win(); | |
| const orig = win.fetch; | |
| if (!orig || orig.__gf_hooked) return; | |
| const self = this; | |
| const wrapped = function (...args) { | |
| return orig.apply(this, args).then((res) => { | |
| try { | |
| const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); | |
| if (!self.#shouldParseYoutubeiUrl(url)) return res; | |
| res | |
| .clone() | |
| .json() | |
| .then((j) => self.#storeFromResponseJson(j)) | |
| .catch(() => {}); | |
| } catch {} | |
| return res; | |
| }); | |
| }; | |
| wrapped.__gf_hooked = true; | |
| win.fetch = wrapped; | |
| } | |
| #hookXHR() { | |
| const win = this.#win(); | |
| const XHR = win.XMLHttpRequest; | |
| if (!XHR || XHR.__gf_hooked) return; | |
| const self = this; | |
| const origOpen = XHR.prototype.open; | |
| const origSend = XHR.prototype.send; | |
| XHR.prototype.open = function (method, url, ...rest) { | |
| this.__gf_url = url; | |
| return origOpen.call(this, method, url, ...rest); | |
| }; | |
| XHR.prototype.send = function (...args) { | |
| this.addEventListener('load', function () { | |
| try { | |
| const url = this.__gf_url || ''; | |
| if (!self.#shouldParseYoutubeiUrl(url)) return; | |
| const ct = this.getResponseHeader('content-type') || ''; | |
| if (!ct.includes('application/json')) return; | |
| const txt = this.responseText; | |
| if (!txt || txt.length < 2) return; | |
| self.#storeFromResponseJson(JSON.parse(txt)); | |
| } catch {} | |
| }); | |
| return origSend.apply(this, args); | |
| }; | |
| XHR.__gf_hooked = true; | |
| } | |
| #hookNetwork() { | |
| this.#hookFetch(); | |
| this.#hookXHR(); | |
| } | |
| /* ------------------------------ 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(); | |
| this.#startObserver(); | |
| this.#startFallbackEnsure(); | |
| } | |
| #bindNavigation() { | |
| window.addEventListener('yt-navigate-finish', () => this.#boot(), true); | |
| window.addEventListener('popstate', () => this.#boot(), true); | |
| } | |
| /* -------------------------------- Events ------------------------------ */ | |
| async #onClick(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const video = this.#video(); | |
| if (!video) return; | |
| if (!this.#isWatch()) return; | |
| this.#toast(this.#t('toastSearching')); | |
| const segs = await this.#getSegmentsOnClickWait(); | |
| 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); | |
| } | |
| } | |
| new MostReplayedJump().run(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment