Last active
February 3, 2026 15:48
-
-
Save LasCC/dcb508c471af48408f34e541f7df0fb4 to your computer and use it in GitHub Desktop.
KickNoSub - Userscript
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 KickNoSub | |
| // @version 2.3.0 | |
| // @description Replaces Kick's native VOD player with a custom hls.js-based player | |
| // @author LasCC | |
| // @match https://kick.com/* | |
| // @icon https://kick.com/favicon.ico | |
| // @require https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_setClipboard | |
| // @connect kick.com | |
| // @connect stream.kick.com | |
| // @connect images.kick.com | |
| // @connect raw.githubusercontent.com | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const STORAGE_KEY = 'kvp_auto_load'; | |
| const POSITION_PREFIX = 'kvp_pos_'; | |
| const POSITION_SAVE_INTERVAL = 5000; // save every 5 seconds | |
| const CONFIG = { | |
| BASE_URLS: [ | |
| 'https://stream.kick.com/ivs/v1/196233775518', | |
| 'https://stream.kick.com/3c81249a5ce0/ivs/v1/196233775518', | |
| 'https://stream.kick.com/0f3cb0ebce7/ivs/v1/196233775518' | |
| ], | |
| TIME_OFFSETS: [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5] | |
| }; | |
| // ── Settings ──────────────────────────────────────────────────────── | |
| function getAutoLoad() { | |
| try { return localStorage.getItem(STORAGE_KEY) === 'true'; } | |
| catch { return false; } | |
| } | |
| function setAutoLoad(val) { | |
| try { localStorage.setItem(STORAGE_KEY, val ? 'true' : 'false'); } | |
| catch {} | |
| } | |
| // ── Position persistence ───────────────────────────────────────────── | |
| function getSavedPosition(videoUuid) { | |
| try { | |
| const raw = localStorage.getItem(POSITION_PREFIX + videoUuid); | |
| if (!raw) return null; | |
| const data = JSON.parse(raw); | |
| // Expire after 30 days | |
| if (Date.now() - data.ts > 30 * 24 * 60 * 60 * 1000) { | |
| localStorage.removeItem(POSITION_PREFIX + videoUuid); | |
| return null; | |
| } | |
| return data.time; | |
| } catch { return null; } | |
| } | |
| function savePosition(videoUuid, time) { | |
| try { | |
| if (!time || time < 5) return; // don't save very early positions | |
| localStorage.setItem(POSITION_PREFIX + videoUuid, JSON.stringify({ time, ts: Date.now() })); | |
| } catch {} | |
| } | |
| function clearSavedPosition(videoUuid) { | |
| try { localStorage.removeItem(POSITION_PREFIX + videoUuid); } | |
| catch {} | |
| } | |
| // ── Styles ────────────────────────────────────────────────────────── | |
| const STYLES = ` | |
| /* ── Overlay button area ── */ | |
| .kvp-overlay { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 99; | |
| pointer-events: none; | |
| } | |
| .kvp-overlay > * { pointer-events: auto; } | |
| /* Card container */ | |
| .kvp-card { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 20px; | |
| background: rgba(0, 0, 0, 0.85); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 16px; | |
| padding: 32px 40px; | |
| backdrop-filter: blur(12px); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); | |
| max-width: 380px; | |
| text-align: center; | |
| } | |
| .kvp-card-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .kvp-card-logo { | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 10px; | |
| } | |
| .kvp-card-title { | |
| font-family: Inter, system-ui, sans-serif; | |
| font-size: 24px; | |
| font-weight: 700; | |
| color: #fff; | |
| margin: 0; | |
| } | |
| .kvp-card-desc { | |
| font-family: Inter, system-ui, sans-serif; | |
| font-size: 14px; | |
| color: rgba(255, 255, 255, 0.7); | |
| line-height: 1.5; | |
| margin: 0; | |
| } | |
| .kvp-card-actions { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| width: 100%; | |
| } | |
| .kvp-replace-btn { | |
| display: inline-flex; | |
| gap: 0.5rem; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 8px; | |
| font-family: Inter, system-ui, sans-serif; | |
| font-weight: 600; | |
| padding: 12px 28px; | |
| font-size: 15px; | |
| background: #53fc18; | |
| color: #000; | |
| border: none; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| white-space: nowrap; | |
| box-shadow: 0 4px 20px rgba(83, 252, 24, 0.3); | |
| width: 100%; | |
| } | |
| .kvp-replace-btn:hover { background: #49de14; transform: scale(0.98); } | |
| .kvp-replace-btn:disabled { | |
| background: #4b5563; color: #9ca3af; | |
| cursor: not-allowed; transform: none; | |
| box-shadow: none; | |
| } | |
| .kvp-replace-btn svg { width: 1.1em; height: 1.1em; fill: currentColor; } | |
| .kvp-auto-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| color: rgba(255, 255, 255, 0.6); | |
| font-family: Inter, system-ui, sans-serif; | |
| font-size: 13px; | |
| cursor: pointer; | |
| user-select: none; | |
| transition: color 0.15s; | |
| } | |
| .kvp-auto-label:hover { color: #fff; } | |
| .kvp-auto-label input { | |
| accent-color: #53fc18; | |
| width: 16px; | |
| height: 16px; | |
| cursor: pointer; | |
| } | |
| .kvp-status-overlay { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 16px; | |
| color: #fff; | |
| font-family: Inter, system-ui, sans-serif; | |
| font-size: 14px; | |
| } | |
| .kvp-status-overlay .kvp-status-icon { | |
| width: 40px; | |
| height: 40px; | |
| flex-shrink: 0; | |
| animation: kvp-pulse 1.5s ease-in-out infinite; | |
| } | |
| .kvp-status-overlay .kvp-status-text { | |
| font-weight: 500; | |
| letter-spacing: 0.2px; | |
| color: rgba(255, 255, 255, 0.8); | |
| } | |
| @keyframes kvp-pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .kvp-status-overlay.kvp-error .kvp-status-text { color: #ef4444; } | |
| .kvp-status-overlay.kvp-error .kvp-status-icon { animation: none; } | |
| /* ── Custom Player ── */ | |
| .kvp-player { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| background: #000; | |
| overflow: hidden; | |
| } | |
| .kvp-player video { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| /* Controls overlay */ | |
| .kvp-controls { | |
| position: absolute; | |
| bottom: 0; left: 0; right: 0; | |
| background: linear-gradient(transparent, rgba(0,0,0,0.85)); | |
| padding: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0; | |
| opacity: 0; | |
| transition: opacity 0.25s; | |
| z-index: 10; | |
| } | |
| .kvp-player:hover .kvp-controls, | |
| .kvp-player.kvp-paused .kvp-controls { opacity: 1; } | |
| .kvp-seek-row { | |
| display: flex; | |
| align-items: center; | |
| width: 100%; | |
| padding: 0; | |
| } | |
| .kvp-seek { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 4px; | |
| border-radius: 0; | |
| background: rgba(255,255,255,0.3); | |
| outline: none; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .kvp-seek::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 0; height: 0; | |
| opacity: 0; | |
| } | |
| .kvp-seek::-moz-range-thumb { | |
| width: 0; height: 0; | |
| opacity: 0; | |
| border: none; | |
| } | |
| .kvp-player:hover .kvp-seek::-webkit-slider-thumb { | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| background: #53fc18; | |
| cursor: pointer; | |
| opacity: 1; | |
| } | |
| .kvp-player:hover .kvp-seek::-moz-range-thumb { | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| background: #53fc18; | |
| border: none; | |
| cursor: pointer; | |
| opacity: 1; | |
| } | |
| .kvp-bottom-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| color: #fff; | |
| font-family: Inter, system-ui, sans-serif; | |
| font-size: 13px; | |
| padding: 8px 12px; | |
| } | |
| .kvp-left-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .kvp-right-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .kvp-branding { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 0 8px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: #53fc18; | |
| } | |
| .kvp-branding img { | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 3px; | |
| } | |
| .kvp-btn { | |
| background: none; border: none; | |
| color: #fff; cursor: pointer; | |
| padding: 6px; display: flex; | |
| align-items: center; justify-content: center; | |
| border-radius: 4px; | |
| transition: background 0.15s; | |
| } | |
| .kvp-btn:hover { background: rgba(255,255,255,0.1); } | |
| .kvp-btn svg { width: 20px; height: 20px; fill: currentColor; } | |
| .kvp-volume { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 60px; height: 3px; | |
| border-radius: 2px; | |
| background: rgba(255,255,255,0.3); | |
| outline: none; | |
| cursor: pointer; | |
| margin-left: -4px; | |
| } | |
| .kvp-volume::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 12px; height: 12px; | |
| border-radius: 50%; | |
| background: #fff; | |
| cursor: pointer; | |
| } | |
| .kvp-volume::-moz-range-thumb { | |
| width: 12px; height: 12px; | |
| border-radius: 50%; | |
| background: #fff; | |
| border: none; | |
| cursor: pointer; | |
| } | |
| .kvp-time { | |
| user-select: none; | |
| white-space: nowrap; | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.9); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .kvp-separator { | |
| width: 1px; | |
| height: 16px; | |
| background: rgba(255,255,255,0.2); | |
| margin: 0 4px; | |
| } | |
| .kvp-spacer { flex: 1; } | |
| .kvp-quality-wrap { position: relative; } | |
| .kvp-quality-btn { | |
| background: rgba(255,255,255,0.15); | |
| border: 1px solid rgba(255,255,255,0.25); | |
| border-radius: 4px; | |
| color: #fff; | |
| font-size: 12px; | |
| padding: 3px 8px; | |
| cursor: pointer; | |
| } | |
| .kvp-quality-btn:hover { background: rgba(255,255,255,0.25); } | |
| .kvp-quality-menu { | |
| position: absolute; | |
| bottom: 100%; | |
| right: 0; | |
| margin-bottom: 6px; | |
| background: #18181b; | |
| border: 1px solid #2f2f35; | |
| border-radius: 6px; | |
| padding: 4px; | |
| min-width: 140px; | |
| display: none; | |
| z-index: 20; | |
| } | |
| .kvp-quality-menu.kvp-open { display: block; } | |
| .kvp-quality-item { | |
| display: block; width: 100%; | |
| padding: 5px 10px; | |
| background: none; border: none; | |
| color: #fff; text-align: left; | |
| cursor: pointer; border-radius: 4px; | |
| font-size: 12px; | |
| } | |
| .kvp-quality-item:hover { background: #2f2f35; } | |
| .kvp-quality-item.kvp-active { color: #53fc18; font-weight: 600; } | |
| /* Speed selector - reuses quality dropdown pattern */ | |
| .kvp-speed-wrap { position: relative; } | |
| .kvp-speed-btn { | |
| background: rgba(255,255,255,0.15); | |
| border: 1px solid rgba(255,255,255,0.25); | |
| border-radius: 4px; | |
| color: #fff; | |
| font-size: 12px; | |
| padding: 3px 8px; | |
| cursor: pointer; | |
| } | |
| .kvp-speed-btn:hover { background: rgba(255,255,255,0.25); } | |
| .kvp-speed-menu { | |
| position: absolute; | |
| bottom: 100%; | |
| right: 0; | |
| margin-bottom: 6px; | |
| background: #18181b; | |
| border: 1px solid #2f2f35; | |
| border-radius: 6px; | |
| padding: 4px; | |
| min-width: 100px; | |
| display: none; | |
| z-index: 20; | |
| } | |
| .kvp-speed-menu.kvp-open { display: block; } | |
| /* Copy URL toast */ | |
| .kvp-toast { | |
| position: absolute; | |
| top: 12px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0,0,0,0.8); | |
| color: #53fc18; | |
| font-family: system-ui, sans-serif; | |
| font-size: 13px; | |
| padding: 6px 14px; | |
| border-radius: 6px; | |
| z-index: 30; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .kvp-toast.kvp-show { opacity: 1; } | |
| /* Skip buttons */ | |
| .kvp-btn svg text { fill: currentColor; } | |
| /* Resume banner */ | |
| .kvp-resume-banner { | |
| position: absolute; | |
| bottom: 70px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0,0,0,0.85); | |
| border: 1px solid #53fc18; | |
| color: #fff; | |
| font-family: system-ui, sans-serif; | |
| font-size: 13px; | |
| padding: 8px 16px; | |
| border-radius: 8px; | |
| z-index: 30; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| white-space: nowrap; | |
| } | |
| .kvp-resume-banner button { | |
| background: #53fc18; | |
| color: #000; | |
| border: none; | |
| border-radius: 4px; | |
| padding: 4px 12px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| } | |
| .kvp-resume-banner button:hover { opacity: 0.85; } | |
| .kvp-resume-dismiss { | |
| background: transparent !important; | |
| color: #aaa !important; | |
| font-size: 11px !important; | |
| padding: 4px 8px !important; | |
| } | |
| .kvp-resume-dismiss:hover { color: #fff !important; } | |
| `; | |
| // ── Utilities ──────────────────────────────────────────────────────── | |
| function isVodPage() { | |
| return /^\/[^/]+\/videos\/[^/]+/.test(window.location.pathname); | |
| } | |
| // Cache to avoid repeated API calls for the same video | |
| const subOnlyCache = new Map(); | |
| async function isSubOnlyVod(videoUuid) { | |
| if (subOnlyCache.has(videoUuid)) return subOnlyCache.get(videoUuid); | |
| try { | |
| const info = await gmFetch(`https://kick.com/api/v1/video/${videoUuid}`); | |
| // If the API returns a playback source, the VOD is freely available | |
| const hasPlayback = !!(info.source || info.playback_url || info.video_url); | |
| const subOnly = !hasPlayback; | |
| subOnlyCache.set(videoUuid, subOnly); | |
| return subOnly; | |
| } catch { | |
| // If the API call fails, assume sub-only so the button still appears | |
| return true; | |
| } | |
| } | |
| function getVideoInfo() { | |
| const parts = window.location.pathname.split('/'); | |
| return { channelName: parts[1], videoUuid: parts[3] }; | |
| } | |
| function gmFetch(url, method = 'GET') { | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method, | |
| url, | |
| headers: { Accept: 'application/json' }, | |
| timeout: 10000, | |
| onload: (r) => { | |
| if (method === 'HEAD') return resolve(r.status === 200); | |
| try { resolve(JSON.parse(r.responseText)); } | |
| catch (e) { reject(new Error('JSON parse error')); } | |
| }, | |
| onerror: () => reject(new Error('Request failed')), | |
| ontimeout: () => method === 'HEAD' ? resolve(false) : reject(new Error('Timeout')) | |
| }); | |
| }); | |
| } | |
| function checkUrl(url) { | |
| return new Promise((resolve) => { | |
| GM_xmlhttpRequest({ | |
| method: 'HEAD', | |
| url, | |
| timeout: 5000, | |
| onload: (r) => resolve(r.status === 200), | |
| onerror: () => resolve(false), | |
| ontimeout: () => resolve(false) | |
| }); | |
| }); | |
| } | |
| function parseThumbnail(url) { | |
| const m = url.match(/video_thumbnails\/([^/]+)\/([^/]+)/); | |
| return m ? { channelId: m[1], videoId: m[2] } : null; | |
| } | |
| function getThumbnailUrl(video) { | |
| if (video.thumbnail?.src) return video.thumbnail.src; | |
| if (video.thumbnail?.url) return video.thumbnail.url; | |
| if (typeof video.thumbnail === 'string') return video.thumbnail; | |
| return null; | |
| } | |
| function getStartTime(video) { | |
| for (const field of ['start_time', 'created_at']) { | |
| if (video[field]) { | |
| const d = new Date(video[field].replace(' ', 'T') + 'Z'); | |
| if (!isNaN(d)) return d; | |
| } | |
| } | |
| return null; | |
| } | |
| function fmtTime(seconds) { | |
| const s = Math.floor(seconds); | |
| const h = Math.floor(s / 3600); | |
| const m = Math.floor((s % 3600) / 60); | |
| const sec = s % 60; | |
| const pad = (n) => String(n).padStart(2, '0'); | |
| return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; | |
| } | |
| // ── Stream discovery ──────────────────────────────────────────────── | |
| async function discoverStream(onStatus) { | |
| const { channelName, videoUuid } = getVideoInfo(); | |
| onStatus('Fetching video metadata...'); | |
| const videoInfo = await gmFetch(`https://kick.com/api/v1/video/${videoUuid}`); | |
| const liveStreamId = videoInfo.live_stream_id; | |
| onStatus('Fetching channel videos...'); | |
| const channelVideos = await gmFetch(`https://kick.com/api/v2/channels/${channelName}/videos`); | |
| const videos = Array.isArray(channelVideos) ? channelVideos : channelVideos.data || channelVideos.videos || []; | |
| const matched = videos.find(v => v.id === liveStreamId); | |
| if (!matched) throw new Error('Video not found in channel videos'); | |
| const thumbUrl = getThumbnailUrl(matched); | |
| if (!thumbUrl) throw new Error('No thumbnail URL'); | |
| const thumbInfo = parseThumbnail(thumbUrl); | |
| if (!thumbInfo) throw new Error('Cannot parse thumbnail'); | |
| const startTime = getStartTime(matched); | |
| if (!startTime) throw new Error('Cannot determine start time'); | |
| const { channelId, videoId } = thumbInfo; | |
| const total = CONFIG.BASE_URLS.length * CONFIG.TIME_OFFSETS.length; | |
| let checked = 0; | |
| for (const offset of CONFIG.TIME_OFFSETS) { | |
| const t = new Date(startTime.getTime() + offset * 60000); | |
| for (const base of CONFIG.BASE_URLS) { | |
| checked++; | |
| onStatus(`Searching... (${checked}/${total}, offset ${offset >= 0 ? '+' : ''}${offset}m)`); | |
| const url = `${base}/${channelId}/${t.getUTCFullYear()}/${t.getUTCMonth() + 1}/${t.getUTCDate()}/${t.getUTCHours()}/${t.getUTCMinutes()}/${videoId}/media/hls/master.m3u8`; | |
| if (await checkUrl(url)) return url; | |
| } | |
| } | |
| throw new Error('No valid stream found after checking all offsets'); | |
| } | |
| // ── SVG icons ─────────────────────────────────────────────────────── | |
| const ICONS = { | |
| play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>', | |
| pause: '<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>', | |
| volumeUp: '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>', | |
| volumeMute: '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>', | |
| fullscreen: '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>', | |
| exitFullscreen: '<svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>', | |
| pip: '<svg viewBox="0 0 24 24"><path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/></svg>', | |
| copy: '<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>', | |
| skip10f: '<svg viewBox="0 0 24 24"><path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/><text x="10" y="15.5" font-size="7" font-weight="bold" text-anchor="middle" fill="currentColor" style="font-family:system-ui">10</text></svg>', | |
| skip10b: '<svg viewBox="0 0 24 24"><path d="M6 13c0 3.31 2.69 6 6 6s6-2.69 6-6-2.69-6-6-6v4l-5-5 5-5v4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8h2z"/><text x="14" y="15.5" font-size="7" font-weight="bold" text-anchor="middle" fill="currentColor" style="font-family:system-ui">10</text></svg>', | |
| }; | |
| // ── Custom Player ─────────────────────────────────────────────────── | |
| function createPlayer(masterUrl, container, videoUuid) { | |
| const player = document.createElement('div'); | |
| player.className = 'kvp-player kvp-paused'; | |
| const video = document.createElement('video'); | |
| video.playsInline = true; | |
| const controls = document.createElement('div'); | |
| controls.className = 'kvp-controls'; | |
| // Seek row | |
| const seekRow = document.createElement('div'); | |
| seekRow.className = 'kvp-seek-row'; | |
| const seekBar = document.createElement('input'); | |
| seekBar.type = 'range'; | |
| seekBar.className = 'kvp-seek'; | |
| seekBar.min = 0; seekBar.max = 1000; seekBar.value = 0; | |
| seekRow.appendChild(seekBar); | |
| // Bottom row | |
| const bottom = document.createElement('div'); | |
| bottom.className = 'kvp-bottom-row'; | |
| // Left controls group | |
| const leftControls = document.createElement('div'); | |
| leftControls.className = 'kvp-left-controls'; | |
| const playBtn = document.createElement('button'); | |
| playBtn.className = 'kvp-btn kvp-play-btn'; | |
| playBtn.innerHTML = ICONS.play; | |
| const muteBtn = document.createElement('button'); | |
| muteBtn.className = 'kvp-btn'; | |
| muteBtn.innerHTML = ICONS.volumeUp; | |
| const volSlider = document.createElement('input'); | |
| volSlider.type = 'range'; | |
| volSlider.className = 'kvp-volume'; | |
| volSlider.min = 0; volSlider.max = 100; volSlider.value = 100; | |
| const timeDisplay = document.createElement('span'); | |
| timeDisplay.className = 'kvp-time'; | |
| timeDisplay.textContent = '0:00 / 0:00'; | |
| // Branding element (KickNoSub with logo) | |
| const branding = document.createElement('div'); | |
| branding.className = 'kvp-branding'; | |
| const brandLogo = document.createElement('img'); | |
| brandLogo.src = 'https://raw.githubusercontent.com/Enmn/KickNoSub/main/logo.png'; | |
| brandLogo.alt = ''; | |
| const brandText = document.createElement('span'); | |
| brandText.textContent = 'KickNoSub'; | |
| branding.append(brandLogo, brandText); | |
| leftControls.append(playBtn, muteBtn, volSlider, timeDisplay, branding); | |
| const spacer = document.createElement('span'); | |
| spacer.className = 'kvp-spacer'; | |
| // Right controls group | |
| const rightControls = document.createElement('div'); | |
| rightControls.className = 'kvp-right-controls'; | |
| // Skip buttons | |
| const skipBackBtn = document.createElement('button'); | |
| skipBackBtn.className = 'kvp-btn'; | |
| skipBackBtn.title = 'Back 10s'; | |
| skipBackBtn.innerHTML = ICONS.skip10b; | |
| const skipFwdBtn = document.createElement('button'); | |
| skipFwdBtn.className = 'kvp-btn'; | |
| skipFwdBtn.title = 'Forward 10s'; | |
| skipFwdBtn.innerHTML = ICONS.skip10f; | |
| // Speed selector | |
| const speedWrap = document.createElement('div'); | |
| speedWrap.className = 'kvp-speed-wrap'; | |
| const speedBtn = document.createElement('button'); | |
| speedBtn.className = 'kvp-speed-btn'; | |
| speedBtn.textContent = '1x'; | |
| speedBtn.title = 'Playback speed'; | |
| const speedMenu = document.createElement('div'); | |
| speedMenu.className = 'kvp-speed-menu'; | |
| speedWrap.append(speedBtn, speedMenu); | |
| const qualWrap = document.createElement('div'); | |
| qualWrap.className = 'kvp-quality-wrap'; | |
| const qualBtn = document.createElement('button'); | |
| qualBtn.className = 'kvp-quality-btn'; | |
| qualBtn.textContent = 'Auto'; | |
| const qualMenu = document.createElement('div'); | |
| qualMenu.className = 'kvp-quality-menu'; | |
| qualWrap.append(qualBtn, qualMenu); | |
| // Copy URL button | |
| const copyBtn = document.createElement('button'); | |
| copyBtn.className = 'kvp-btn'; | |
| copyBtn.title = 'Copy stream URL'; | |
| copyBtn.innerHTML = ICONS.copy; | |
| // PiP button | |
| const pipBtn = document.createElement('button'); | |
| pipBtn.className = 'kvp-btn'; | |
| pipBtn.title = 'Picture-in-Picture'; | |
| pipBtn.innerHTML = ICONS.pip; | |
| const fsBtn = document.createElement('button'); | |
| fsBtn.className = 'kvp-btn'; | |
| fsBtn.title = 'Fullscreen'; | |
| fsBtn.innerHTML = ICONS.fullscreen; | |
| rightControls.append(skipBackBtn, skipFwdBtn, speedWrap, qualWrap, copyBtn, pipBtn, fsBtn); | |
| bottom.append(leftControls, spacer, rightControls); | |
| controls.append(seekRow, bottom); | |
| player.append(video, controls); | |
| container.innerHTML = ''; | |
| container.appendChild(player); | |
| // ── hls.js setup ── | |
| let hls = null; | |
| let currentLevel = -1; | |
| if (Hls.isSupported()) { | |
| hls = new Hls({ | |
| autoStartLoad: true, | |
| startLevel: -1, | |
| fragLoadingMaxRetry: 10, | |
| fragLoadingRetryDelay: 1000, | |
| fragLoadingMaxRetryTimeout: 30000, | |
| manifestLoadingMaxRetry: 6, | |
| manifestLoadingRetryDelay: 1000, | |
| manifestLoadingMaxRetryTimeout: 15000, | |
| levelLoadingMaxRetry: 6, | |
| levelLoadingRetryDelay: 1000, | |
| levelLoadingMaxRetryTimeout: 15000, | |
| maxBufferHole: 2, | |
| maxBufferLength: 60, | |
| maxMaxBufferLength: 120, | |
| highBufferWatchdogPeriod: 2, | |
| nudgeMaxRetry: 10, | |
| }); | |
| hls.loadSource(masterUrl); | |
| hls.attachMedia(video); | |
| hls.on(Hls.Events.MANIFEST_PARSED, () => { | |
| buildQualityMenu(); | |
| const savedTime = videoUuid ? getSavedPosition(videoUuid) : null; | |
| if (savedTime) { | |
| showResumeBanner(savedTime); | |
| } | |
| video.play().catch(() => {}); | |
| }); | |
| hls.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => { | |
| updateQualityLabel(data.level); | |
| }); | |
| let networkRetries = 0; | |
| const MAX_NETWORK_RETRIES = 5; | |
| hls.on(Hls.Events.ERROR, (_e, data) => { | |
| if (data.fatal) { | |
| if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { | |
| networkRetries++; | |
| if (networkRetries <= MAX_NETWORK_RETRIES) { | |
| setTimeout(() => hls.startLoad(), 1000 * networkRetries); | |
| } | |
| } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { | |
| hls.recoverMediaError(); | |
| } | |
| } else if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR && data.response?.code === 403) { | |
| // Non-fatal 403 on a fragment — hls.js will retry internally, | |
| // but if we're on a high quality, try dropping to let it recover | |
| if (hls.currentLevel > 0 && hls.autoLevelEnabled) { | |
| hls.nextLevel = Math.max(0, hls.currentLevel - 1); | |
| } | |
| } | |
| }); | |
| // Reset fatal-retry counter on successful playback | |
| video.addEventListener('playing', () => { networkRetries = 0; }); | |
| } else if (video.canPlayType('application/vnd.apple.mpegurl')) { | |
| video.src = masterUrl; | |
| video.addEventListener('loadedmetadata', () => { | |
| const savedTime = videoUuid ? getSavedPosition(videoUuid) : null; | |
| if (savedTime) { | |
| showResumeBanner(savedTime); | |
| } | |
| video.play().catch(() => {}); | |
| }); | |
| } | |
| // ── Quality menu ── | |
| function buildQualityMenu() { | |
| if (!hls) return; | |
| qualMenu.innerHTML = ''; | |
| const autoItem = document.createElement('button'); | |
| autoItem.className = 'kvp-quality-item kvp-active'; | |
| autoItem.textContent = 'Auto'; | |
| autoItem.addEventListener('click', () => { | |
| hls.currentLevel = -1; | |
| currentLevel = -1; | |
| setActiveQuality(-1); | |
| qualMenu.classList.remove('kvp-open'); | |
| }); | |
| qualMenu.appendChild(autoItem); | |
| hls.levels.forEach((level, i) => { | |
| const item = document.createElement('button'); | |
| item.className = 'kvp-quality-item'; | |
| const label = level.height ? `${level.height}p` : `Level ${i}`; | |
| const fps = level.attrs?.['FRAME-RATE'] ? Math.round(parseFloat(level.attrs['FRAME-RATE'])) : null; | |
| item.textContent = fps && fps > 30 ? `${label}${fps}` : label; | |
| item.dataset.level = i; | |
| item.addEventListener('click', () => { | |
| hls.currentLevel = i; | |
| currentLevel = i; | |
| setActiveQuality(i); | |
| qualMenu.classList.remove('kvp-open'); | |
| }); | |
| qualMenu.appendChild(item); | |
| }); | |
| } | |
| function setActiveQuality(level) { | |
| qualMenu.querySelectorAll('.kvp-quality-item').forEach((el, idx) => { | |
| el.classList.toggle('kvp-active', idx === 0 ? level === -1 : parseInt(el.dataset.level) === level); | |
| }); | |
| } | |
| function updateQualityLabel(levelIndex) { | |
| if (!hls || !hls.levels[levelIndex]) return; | |
| const lvl = hls.levels[levelIndex]; | |
| const label = lvl.height ? `${lvl.height}p` : `Level ${levelIndex}`; | |
| qualBtn.textContent = currentLevel === -1 ? `Auto (${label})` : label; | |
| } | |
| // ── Control bindings ── | |
| playBtn.addEventListener('click', () => togglePlay()); | |
| video.addEventListener('click', () => togglePlay()); | |
| function togglePlay() { | |
| if (video.paused) video.play(); | |
| else video.pause(); | |
| } | |
| video.addEventListener('play', () => { | |
| playBtn.innerHTML = ICONS.pause; | |
| player.classList.remove('kvp-paused'); | |
| }); | |
| video.addEventListener('pause', () => { | |
| playBtn.innerHTML = ICONS.play; | |
| player.classList.add('kvp-paused'); | |
| }); | |
| let seeking = false; | |
| seekBar.addEventListener('input', () => { | |
| seeking = true; | |
| if (video.duration) { | |
| video.currentTime = (seekBar.value / 1000) * video.duration; | |
| } | |
| }); | |
| seekBar.addEventListener('change', () => { seeking = false; }); | |
| video.addEventListener('timeupdate', () => { | |
| if (!seeking && video.duration) { | |
| seekBar.value = (video.currentTime / video.duration) * 1000; | |
| } | |
| timeDisplay.textContent = `${fmtTime(video.currentTime)} / ${fmtTime(video.duration || 0)}`; | |
| }); | |
| function updateSeekStyle() { | |
| const pct = (seekBar.value / 1000) * 100; | |
| seekBar.style.background = `linear-gradient(to right, #53fc18 ${pct}%, rgba(255,255,255,0.3) ${pct}%)`; | |
| } | |
| seekBar.addEventListener('input', updateSeekStyle); | |
| video.addEventListener('timeupdate', updateSeekStyle); | |
| function updateVolumeStyle() { | |
| const pct = volSlider.value; | |
| volSlider.style.background = `linear-gradient(to right, #fff ${pct}%, rgba(255,255,255,0.3) ${pct}%)`; | |
| } | |
| updateVolumeStyle(); | |
| volSlider.addEventListener('input', () => { | |
| video.volume = volSlider.value / 100; | |
| video.muted = false; | |
| updateMuteIcon(); | |
| updateVolumeStyle(); | |
| }); | |
| muteBtn.addEventListener('click', () => { | |
| video.muted = !video.muted; | |
| updateMuteIcon(); | |
| }); | |
| function updateMuteIcon() { | |
| muteBtn.innerHTML = (video.muted || video.volume === 0) ? ICONS.volumeMute : ICONS.volumeUp; | |
| if (!video.muted) { | |
| volSlider.value = video.volume * 100; | |
| updateVolumeStyle(); | |
| } | |
| } | |
| qualBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| qualMenu.classList.toggle('kvp-open'); | |
| speedMenu.classList.remove('kvp-open'); | |
| }); | |
| // ── Speed controls ── | |
| const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; | |
| SPEEDS.forEach(speed => { | |
| const item = document.createElement('button'); | |
| item.className = 'kvp-quality-item' + (speed === 1 ? ' kvp-active' : ''); | |
| item.textContent = speed === 1 ? 'Normal' : `${speed}x`; | |
| item.dataset.speed = speed; | |
| item.addEventListener('click', () => { | |
| video.playbackRate = speed; | |
| speedBtn.textContent = speed === 1 ? '1x' : `${speed}x`; | |
| speedMenu.querySelectorAll('.kvp-quality-item').forEach(el => { | |
| el.classList.toggle('kvp-active', parseFloat(el.dataset.speed) === speed); | |
| }); | |
| speedMenu.classList.remove('kvp-open'); | |
| }); | |
| speedMenu.appendChild(item); | |
| }); | |
| speedBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| speedMenu.classList.toggle('kvp-open'); | |
| qualMenu.classList.remove('kvp-open'); | |
| }); | |
| // ── Skip buttons ── | |
| skipBackBtn.addEventListener('click', () => { | |
| video.currentTime = Math.max(0, video.currentTime - 10); | |
| }); | |
| skipFwdBtn.addEventListener('click', () => { | |
| video.currentTime = Math.min(video.duration || 0, video.currentTime + 10); | |
| }); | |
| // ── PiP ── | |
| pipBtn.addEventListener('click', async () => { | |
| try { | |
| if (document.pictureInPictureElement) { | |
| await document.exitPictureInPicture(); | |
| } else { | |
| await video.requestPictureInPicture(); | |
| } | |
| } catch {} | |
| }); | |
| // ── Copy URL ── | |
| const toast = document.createElement('div'); | |
| toast.className = 'kvp-toast'; | |
| toast.textContent = 'URL copied!'; | |
| player.appendChild(toast); | |
| copyBtn.addEventListener('click', () => { | |
| try { | |
| GM_setClipboard(masterUrl); | |
| } catch { | |
| navigator.clipboard.writeText(masterUrl).catch(() => {}); | |
| } | |
| toast.classList.add('kvp-show'); | |
| setTimeout(() => toast.classList.remove('kvp-show'), 1500); | |
| }); | |
| // Close menus on general click | |
| player.addEventListener('click', (e) => { | |
| if (!qualWrap.contains(e.target)) qualMenu.classList.remove('kvp-open'); | |
| if (!speedWrap.contains(e.target)) speedMenu.classList.remove('kvp-open'); | |
| }); | |
| fsBtn.addEventListener('click', () => { | |
| if (document.fullscreenElement === player) { | |
| document.exitFullscreen(); | |
| } else { | |
| player.requestFullscreen().catch(() => {}); | |
| } | |
| }); | |
| document.addEventListener('fullscreenchange', () => { | |
| fsBtn.innerHTML = document.fullscreenElement === player ? ICONS.exitFullscreen : ICONS.fullscreen; | |
| }); | |
| player.tabIndex = 0; | |
| player.addEventListener('keydown', (e) => { | |
| switch (e.key) { | |
| case ' ': | |
| case 'k': | |
| e.preventDefault(); | |
| togglePlay(); | |
| break; | |
| case 'ArrowLeft': | |
| e.preventDefault(); | |
| video.currentTime = Math.max(0, video.currentTime - 10); | |
| break; | |
| case 'ArrowRight': | |
| e.preventDefault(); | |
| video.currentTime = Math.min(video.duration || 0, video.currentTime + 10); | |
| break; | |
| case 'ArrowUp': | |
| e.preventDefault(); | |
| video.volume = Math.min(1, video.volume + 0.1); | |
| volSlider.value = video.volume * 100; | |
| updateMuteIcon(); | |
| break; | |
| case 'ArrowDown': | |
| e.preventDefault(); | |
| video.volume = Math.max(0, video.volume - 0.1); | |
| volSlider.value = video.volume * 100; | |
| updateMuteIcon(); | |
| break; | |
| case 'm': | |
| video.muted = !video.muted; | |
| updateMuteIcon(); | |
| break; | |
| case 'f': | |
| fsBtn.click(); | |
| break; | |
| case 'p': | |
| pipBtn.click(); | |
| break; | |
| case ',': | |
| case '<': { | |
| e.preventDefault(); | |
| const idx = SPEEDS.indexOf(video.playbackRate); | |
| if (idx > 0) { | |
| video.playbackRate = SPEEDS[idx - 1]; | |
| speedBtn.textContent = SPEEDS[idx - 1] === 1 ? '1x' : `${SPEEDS[idx - 1]}x`; | |
| speedMenu.querySelectorAll('.kvp-quality-item').forEach(el => { | |
| el.classList.toggle('kvp-active', parseFloat(el.dataset.speed) === SPEEDS[idx - 1]); | |
| }); | |
| } | |
| break; | |
| } | |
| case '.': | |
| case '>': { | |
| e.preventDefault(); | |
| const idx = SPEEDS.indexOf(video.playbackRate); | |
| if (idx < SPEEDS.length - 1) { | |
| video.playbackRate = SPEEDS[idx + 1]; | |
| speedBtn.textContent = SPEEDS[idx + 1] === 1 ? '1x' : `${SPEEDS[idx + 1]}x`; | |
| speedMenu.querySelectorAll('.kvp-quality-item').forEach(el => { | |
| el.classList.toggle('kvp-active', parseFloat(el.dataset.speed) === SPEEDS[idx + 1]); | |
| }); | |
| } | |
| break; | |
| } | |
| } | |
| }); | |
| video.addEventListener('dblclick', () => fsBtn.click()); | |
| let idleTimer; | |
| player.addEventListener('mousemove', () => { | |
| player.style.cursor = ''; | |
| controls.style.opacity = '1'; | |
| clearTimeout(idleTimer); | |
| idleTimer = setTimeout(() => { | |
| if (!video.paused) { | |
| controls.style.opacity = '0'; | |
| if (document.fullscreenElement === player) { | |
| player.style.cursor = 'none'; | |
| } | |
| } | |
| }, 3000); | |
| }); | |
| player.addEventListener('mouseleave', () => { | |
| clearTimeout(idleTimer); | |
| if (!video.paused) { | |
| idleTimer = setTimeout(() => { | |
| controls.style.opacity = '0'; | |
| }, 1000); | |
| } | |
| }); | |
| // ── Resume banner ── | |
| function showResumeBanner(savedTime) { | |
| const banner = document.createElement('div'); | |
| banner.className = 'kvp-resume-banner'; | |
| banner.innerHTML = `Resume from <strong>${fmtTime(savedTime)}</strong>?`; | |
| const resumeBtn = document.createElement('button'); | |
| resumeBtn.textContent = 'Resume'; | |
| resumeBtn.addEventListener('click', () => { | |
| video.currentTime = savedTime; | |
| banner.remove(); | |
| }); | |
| const dismissBtn = document.createElement('button'); | |
| dismissBtn.className = 'kvp-resume-dismiss'; | |
| dismissBtn.textContent = 'Start over'; | |
| dismissBtn.addEventListener('click', () => { | |
| if (videoUuid) clearSavedPosition(videoUuid); | |
| banner.remove(); | |
| }); | |
| banner.append(resumeBtn, dismissBtn); | |
| player.appendChild(banner); | |
| // Auto-dismiss after 10s | |
| setTimeout(() => banner.remove(), 10000); | |
| } | |
| // ── Position saving ── | |
| if (videoUuid) { | |
| let saveTimer = null; | |
| function startSaving() { | |
| if (saveTimer) return; | |
| saveTimer = setInterval(() => { | |
| if (!video.paused && video.currentTime > 5) { | |
| savePosition(videoUuid, video.currentTime); | |
| } | |
| }, POSITION_SAVE_INTERVAL); | |
| } | |
| function stopSaving() { | |
| if (saveTimer) { clearInterval(saveTimer); saveTimer = null; } | |
| } | |
| video.addEventListener('play', startSaving); | |
| video.addEventListener('pause', () => { | |
| savePosition(videoUuid, video.currentTime); | |
| stopSaving(); | |
| }); | |
| video.addEventListener('ended', () => { | |
| clearSavedPosition(videoUuid); | |
| stopSaving(); | |
| }); | |
| window.addEventListener('beforeunload', () => { | |
| if (video.currentTime > 5) { | |
| savePosition(videoUuid, video.currentTime); | |
| } | |
| }); | |
| } | |
| return { player, video, hls }; | |
| } | |
| // ── Player container detection ────────────────────────────────────── | |
| function findPlayerContainer() { | |
| const selectors = [ | |
| '.video-player', | |
| '[class*="video-player"]', | |
| '[data-testid="video-player"]', | |
| ]; | |
| for (const sel of selectors) { | |
| const el = document.querySelector(sel); | |
| if (el) return el; | |
| } | |
| const vid = document.querySelector('video'); | |
| if (vid) { | |
| let parent = vid.parentElement; | |
| while (parent && parent !== document.body) { | |
| const rect = parent.getBoundingClientRect(); | |
| if (rect.width >= 300 && rect.height >= 200) return parent; | |
| parent = parent.parentElement; | |
| } | |
| } | |
| // Sub-only VOD fallback: thumbnail + overlay container | |
| const thumb = document.querySelector('img[data-thumbnail="true"]'); | |
| if (thumb) { | |
| let parent = thumb.parentElement; | |
| while (parent && parent !== document.body) { | |
| const rect = parent.getBoundingClientRect(); | |
| if (rect.width >= 300 && rect.height >= 200) { | |
| const outerParent = parent.parentElement; | |
| if (outerParent && outerParent !== document.body) { | |
| const outerRect = outerParent.getBoundingClientRect(); | |
| if (outerRect.width >= 300 && outerRect.height >= 200 && | |
| outerRect.height < window.innerHeight) { | |
| return outerParent; | |
| } | |
| } | |
| return parent; | |
| } | |
| parent = parent.parentElement; | |
| } | |
| } | |
| return null; | |
| } | |
| // ── Overlay with button on the player area ────────────────────────── | |
| // Shared state to prevent duplicate triggers | |
| let isLoading = false; | |
| async function triggerReplace(statusEl, statusText) { | |
| if (isLoading) return; | |
| isLoading = true; | |
| try { | |
| const playerContainer = findPlayerContainer(); | |
| if (!playerContainer) throw new Error('Could not find video player container'); | |
| statusText.textContent = 'Finding stream...'; | |
| statusEl.classList.remove('kvp-error'); | |
| const masterUrl = await discoverStream((msg) => { | |
| statusText.textContent = msg; | |
| }); | |
| statusText.textContent = 'Loading player...'; | |
| const { videoUuid } = getVideoInfo(); | |
| createPlayer(masterUrl, playerContainer, videoUuid); | |
| // Clean up the overlay (it's inside playerContainer, so already gone) | |
| removeOverlay(); | |
| } catch (err) { | |
| statusText.textContent = `Error: ${err.message}`; | |
| statusEl.classList.add('kvp-error'); | |
| isLoading = false; | |
| } | |
| } | |
| function removeOverlay() { | |
| document.querySelector('.kvp-overlay')?.remove(); | |
| isLoading = false; | |
| } | |
| async function injectOverlay() { | |
| if (!isVodPage()) { | |
| removeOverlay(); | |
| return false; | |
| } | |
| const { videoUuid } = getVideoInfo(); | |
| if (videoUuid && !(await isSubOnlyVod(videoUuid))) { | |
| return false; | |
| } | |
| injectStyles(); | |
| // Don't inject if already present or player is active | |
| if (document.querySelector('.kvp-overlay') || document.querySelector('.kvp-player')) return true; | |
| const playerContainer = findPlayerContainer(); | |
| if (!playerContainer) return false; | |
| // Ensure container can hold absolute children | |
| const style = getComputedStyle(playerContainer); | |
| if (style.position === 'static') { | |
| playerContainer.style.position = 'relative'; | |
| } | |
| // Hide Kick's subscriber-only overlay so it doesn't block our controls | |
| const subOverlay = playerContainer.querySelector('[data-testid="video-subscriber-only"]'); | |
| if (subOverlay) { | |
| // Walk up to the backdrop-blur overlay container | |
| let wall = subOverlay.closest('[class*="z-absolute"]') || subOverlay.parentElement?.parentElement; | |
| if (wall) wall.style.display = 'none'; | |
| } | |
| // Build overlay | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'kvp-overlay'; | |
| // Card container | |
| const card = document.createElement('div'); | |
| card.className = 'kvp-card'; | |
| // Header with logo and title | |
| const header = document.createElement('div'); | |
| header.className = 'kvp-card-header'; | |
| const logo = document.createElement('img'); | |
| logo.className = 'kvp-card-logo'; | |
| logo.src = 'https://raw.githubusercontent.com/Enmn/KickNoSub/main/logo.png'; | |
| logo.alt = 'KickNoSub'; | |
| const title = document.createElement('h2'); | |
| title.className = 'kvp-card-title'; | |
| title.textContent = 'KickNoSub'; | |
| header.append(logo, title); | |
| // Description | |
| const desc = document.createElement('p'); | |
| desc.className = 'kvp-card-desc'; | |
| desc.textContent = 'Watch subscriber-only VODs for free. Click below to replace the native player with our custom HLS player.'; | |
| // Replace Player button | |
| const button = document.createElement('button'); | |
| button.className = 'kvp-replace-btn'; | |
| button.innerHTML = ` | |
| <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M8 5v14l11-7z"/> | |
| </svg> | |
| <span>Replace Player</span> | |
| `; | |
| // Auto-load checkbox | |
| const autoLabel = document.createElement('label'); | |
| autoLabel.className = 'kvp-auto-label'; | |
| const autoCheck = document.createElement('input'); | |
| autoCheck.type = 'checkbox'; | |
| autoCheck.checked = getAutoLoad(); | |
| autoCheck.addEventListener('change', () => { | |
| setAutoLoad(autoCheck.checked); | |
| }); | |
| autoLabel.append(autoCheck, 'Auto load VODs'); | |
| // Status with logo (shown when loading) | |
| const statusEl = document.createElement('div'); | |
| statusEl.className = 'kvp-status-overlay'; | |
| statusEl.style.display = 'none'; | |
| const statusIcon = document.createElement('img'); | |
| statusIcon.className = 'kvp-status-icon'; | |
| statusIcon.src = 'https://raw.githubusercontent.com/Enmn/KickNoSub/main/logo.png'; | |
| statusIcon.alt = ''; | |
| const statusText = document.createElement('span'); | |
| statusText.className = 'kvp-status-text'; | |
| statusEl.append(statusIcon, statusText); | |
| // Action container (button + checkbox, or status) | |
| const actionContainer = document.createElement('div'); | |
| actionContainer.className = 'kvp-card-actions'; | |
| actionContainer.append(button, autoLabel); | |
| button.addEventListener('click', () => { | |
| button.disabled = true; | |
| actionContainer.style.display = 'none'; | |
| statusEl.style.display = ''; | |
| triggerReplace(statusEl, statusText); | |
| }); | |
| card.append(header, desc, actionContainer, statusEl); | |
| overlay.appendChild(card); | |
| playerContainer.appendChild(overlay); | |
| // If auto-load is enabled, trigger immediately | |
| if (getAutoLoad()) { | |
| actionContainer.style.display = 'none'; | |
| statusEl.style.display = ''; | |
| triggerReplace(statusEl, statusText); | |
| } | |
| return true; | |
| } | |
| // ── Styles injection ──────────────────────────────────────────────── | |
| function injectStyles() { | |
| if (document.getElementById('kvp-styles')) return; | |
| const el = document.createElement('style'); | |
| el.id = 'kvp-styles'; | |
| el.textContent = STYLES; | |
| document.head.appendChild(el); | |
| } | |
| // ── Init ──────────────────────────────────────────────────────────── | |
| function init() { | |
| let injecting = false; | |
| function tryInject() { | |
| if (injecting || document.querySelector('.kvp-overlay') || document.querySelector('.kvp-player')) return; | |
| if (!isVodPage()) { removeOverlay(); return; } | |
| injecting = true; | |
| injectOverlay().finally(() => { injecting = false; }); | |
| } | |
| tryInject(); | |
| const observer = new MutationObserver(() => tryInject()); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| let lastUrl = location.href; | |
| setInterval(() => { | |
| if (location.href !== lastUrl) { | |
| lastUrl = location.href; | |
| isLoading = false; | |
| subOnlyCache.clear(); | |
| if (isVodPage()) { | |
| setTimeout(tryInject, 300); | |
| setTimeout(tryInject, 800); | |
| setTimeout(tryInject, 1500); | |
| } else { | |
| removeOverlay(); | |
| } | |
| } | |
| }, 300); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment