-
-
Save nwgat/d178ba69c2eaf727e5746d5f8f0d689e to your computer and use it in GitHub Desktop.
nnqs
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 NRK TV - Native Quality Selector (Resolution & FPS Only) | |
| // @namespace Violentmonkey Scripts | |
| // @match *://tv.nrk.no/* | |
| // @grant GM_setValue | |
| // @grant GM_getValue | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| const UI_ID = 'nrk-native-hq-wrapper'; | |
| // --- CONFIGURATION TIERS --- | |
| const TIERS = [ | |
| { label: "1080p • Full HD", maxRes: 1080, maxFps: 60 }, | |
| { label: "720p • High (50fps)", maxRes: 720, maxFps: 60 }, | |
| { label: "720p • Low (25fps)", maxRes: 720, maxFps: 30 }, | |
| { label: "540p • SD", maxRes: 540, maxFps: 30 } | |
| ]; | |
| let currentTierIndex = GM_getValue('nrk_quality_tier_index_v6', 0); | |
| if (currentTierIndex < 0 || currentTierIndex >= TIERS.length) currentTierIndex = 0; | |
| let currentLimit = TIERS[currentTierIndex]; | |
| let detectedMaxHeight = 0; | |
| // --- 1. MANIFEST FILTERING --- | |
| function filterManifest(text) { | |
| if (!text.includes('#EXT-X-STREAM-INF')) return null; | |
| const lines = text.split('\n'); | |
| const headerLines = []; | |
| const variants = []; | |
| let currentVariantLines = []; | |
| let isParsingVariant = false; | |
| for (let line of lines) { | |
| const trimmed = line.trim(); | |
| if (trimmed.startsWith('#EXT-X-STREAM-INF')) { | |
| isParsingVariant = true; | |
| currentVariantLines = [line]; | |
| } else if (isParsingVariant) { | |
| currentVariantLines.push(line); | |
| if (!trimmed.startsWith('#') && trimmed.length > 0) { | |
| variants.push({ | |
| lines: currentVariantLines, | |
| ...extractVariantStats(currentVariantLines) | |
| }); | |
| isParsingVariant = false; | |
| } | |
| } else { | |
| headerLines.push(line); | |
| } | |
| } | |
| // Update UI max detection | |
| let localMax = 0; | |
| variants.forEach(v => { if(v.height > localMax) localMax = v.height; }); | |
| if (localMax > 0 && localMax !== detectedMaxHeight) { | |
| detectedMaxHeight = localMax; | |
| window.dispatchEvent(new CustomEvent('nrk-quality-update', { detail: localMax })); | |
| } | |
| // Filter Logic: Ignore bitrate, focus only on Res and FPS | |
| const keptVariants = variants.filter(v => { | |
| const resMatch = v.height <= currentLimit.maxRes; | |
| // If the manifest doesn't list FPS, we assume it's safe (fallback) | |
| // Otherwise, we check if it's over the limit (e.g., 50 > 30) | |
| const fpsMatch = (v.fps === 0 || v.fps <= currentLimit.maxFps); | |
| return resMatch && fpsMatch; | |
| }); | |
| // If the FPS filter was too strict (NRK didn't provide a 25fps version for that res), | |
| // we take the lowest available version of that resolution instead of dropping to 540p. | |
| if (keptVariants.length === 0 && variants.length > 0) { | |
| const sameRes = variants.filter(v => v.height === currentLimit.maxRes); | |
| if (sameRes.length > 0) { | |
| // Sort by bandwidth just to pick the most efficient one as a fallback | |
| sameRes.sort((a, b) => a.bandwidth - b.bandwidth); | |
| keptVariants.push(sameRes[0]); | |
| } else { | |
| keptVariants.push(variants[0]); | |
| } | |
| } | |
| return [ | |
| ...headerLines, | |
| ...keptVariants.map(v => v.lines.join('\n')) | |
| ].join('\n'); | |
| } | |
| function extractVariantStats(lines) { | |
| let height = 0, bandwidth = 0, fps = 0; | |
| const infLine = lines.find(l => l.startsWith('#EXT-X-STREAM-INF')); | |
| if (infLine) { | |
| const rMatch = infLine.match(/RESOLUTION=\d+x(\d+)/); | |
| if (rMatch) height = parseInt(rMatch[1]); | |
| const bMatch = infLine.match(/BANDWIDTH=(\d+)/); | |
| if (bMatch) bandwidth = parseInt(bMatch[1]); | |
| const fMatch = infLine.match(/FRAME-RATE=([\d.]+)/); | |
| if (fMatch) fps = parseFloat(fMatch[1]); | |
| } | |
| return { height, bandwidth, fps }; | |
| } | |
| // --- 2. NETWORK INTERCEPTION --- | |
| const { fetch: originalFetch } = window; | |
| window.fetch = async (...args) => { | |
| const url = (typeof args[0] === 'string') ? args[0] : args[0].url; | |
| if (url && (url.includes('.m3u8') || url.includes('manifest'))) { | |
| const response = await originalFetch(...args); | |
| const clone = response.clone(); | |
| const text = await clone.text(); | |
| if (text.includes('#EXT-X-STREAM-INF')) { | |
| const filtered = filterManifest(text); | |
| if (filtered) { | |
| const newHeaders = new Headers(response.headers); | |
| newHeaders.delete('Content-Length'); | |
| return new Response(filtered, { status: 200, statusText: 'OK', headers: newHeaders }); | |
| } | |
| } | |
| } | |
| return originalFetch(...args); | |
| }; | |
| const xhrProto = XMLHttpRequest.prototype; | |
| const responseTextDesc = Object.getOwnPropertyDescriptor(xhrProto, 'responseText'); | |
| const origOpen = xhrProto.open; | |
| const origSend = xhrProto.send; | |
| Object.defineProperty(xhrProto, 'responseText', { get: function() { return this._hackedResponse || responseTextDesc.get.call(this); } }); | |
| xhrProto.open = function(method, url) { this._targetUrl = url; return origOpen.apply(this, arguments); }; | |
| xhrProto.send = function() { | |
| if (this._targetUrl && (this._targetUrl.includes('.m3u8') || this._targetUrl.includes('manifest'))) { | |
| const fnStateChange = this.onreadystatechange; | |
| this.onreadystatechange = function() { | |
| if (this.readyState === 4 && this.status === 200) { | |
| const rawText = responseTextDesc.get.call(this); | |
| if (rawText && rawText.includes('#EXT-X-STREAM-INF')) { | |
| const cleanText = filterManifest(rawText); | |
| if (cleanText) this._hackedResponse = cleanText; | |
| } | |
| } | |
| if (fnStateChange) fnStateChange.apply(this, arguments); | |
| }; | |
| } | |
| return origSend.apply(this, arguments); | |
| }; | |
| // --- 3. UI --- | |
| function createUI(targetContainer) { | |
| if (document.getElementById(UI_ID)) return; | |
| if (getComputedStyle(targetContainer).position === 'static') targetContainer.style.position = 'relative'; | |
| const wrapper = document.createElement('a'); | |
| wrapper.id = UI_ID; | |
| wrapper.href = "#"; | |
| wrapper.style.cssText = `position: absolute; right: -110px; top: 0; height: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; color: white; text-decoration: none; cursor: pointer; z-index: 9000; white-space: nowrap;`; | |
| wrapper.innerHTML = `<span style="display: flex; align-items: center;"><svg viewBox="0 0 24 24" width="1.5em" height="1.5em" fill="currentColor"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zM13 21v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zM21 13v-2H11v2h10zM15 9h2V7h4V5h-4V3h-2v6z"/></svg></span><span style="font-size: 14px; font-weight: 600; font-family: 'NRK Sans', sans-serif;">Kvalitet</span>`; | |
| const menu = document.createElement('div'); | |
| menu.style.cssText = `position: absolute; top: 100%; right: 0; background: #1d1d1d; border: 1px solid #333; border-radius: 4px; padding: 5px 0; display: none; flex-direction: column; width: 220px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); z-index: 10000; margin-top: 5px;`; | |
| TIERS.forEach((tier, index) => { | |
| const item = document.createElement('div'); | |
| item.innerText = tier.label; | |
| const isSelected = currentTierIndex === index; | |
| item.style.cssText = `padding: 10px 16px; font-size: 14px; color: ${isSelected ? '#00b9f2' : '#ddd'}; cursor: pointer; font-family: 'NRK Sans', sans-serif; font-weight: ${isSelected ? '600' : '400'}; border-bottom: 1px solid rgba(255,255,255,0.05);`; | |
| item.onclick = (e) => { | |
| e.preventDefault(); | |
| GM_setValue('nrk_quality_tier_index_v6', index); | |
| location.reload(); | |
| }; | |
| menu.appendChild(item); | |
| }); | |
| wrapper.onclick = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| menu.style.display = menu.style.display === 'none' ? 'flex' : 'none'; | |
| }; | |
| document.addEventListener('click', () => menu.style.display = 'none'); | |
| wrapper.appendChild(menu); | |
| targetContainer.appendChild(wrapper); | |
| } | |
| setInterval(() => { | |
| const profileLink = document.querySelector('a[data-testid="my-content-link"]') || document.querySelector('a[data-testid="my-content-login-link"]'); | |
| if (profileLink && profileLink.parentElement) createUI(profileLink.parentElement); | |
| }, 1000); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment