치지직(chzzk) 방송 초기 화질을 1080p로 고정합니다.
// ==UserScript==
// @name CHZZK Initial Highest Quality (Internal API)
// @name:ko 치지직 초기화질을 최고화질로 고정하는 스크립트 (내부 API사용)
// @namespace local/scriptcat/chzzk-initial-highest-quality-internal
// @version 0.1.9
// @description 치지직(chzzk) 방송 초기 화질을 1080p로 고정합니다.
// @author 떱_
// @match https://chzzk.naver.com/live/*
// @match https://chzzk.naver.com/?multiview=true
// @match https://chzzk.naver.com/lives
// @run-at document-start
// @grant none
// @license MIT
// @noframes
// ==/UserScript==
(() => {
'use strict';
if (window.__CHZZK_INITIAL_HIGHEST_QUALITY_INTERNAL_V18__) return;
window.__CHZZK_INITIAL_HIGHEST_QUALITY_INTERNAL_V18__ = true;
const NAME = 'CHZZK Initial Highest Quality (Internal API)';
const VERSION = '0.1.9';
const CONFIG = {
maxWaitMs: 30000,
pollIntervalMs: 180,
selectionVerifyMs: 3000,
passiveResumeWaitMs: 400,
resumeVerifyMs: 1500,
maxApplyAttempts: 2,
routeCheckMs: 1000,
targetStabilityPolls: 2,
radioConfirmMs: 300,
requireFreshChannelResource: true,
freshResourceSettleMs: 100,
acceptOpaqueStreamResource: true,
bypassRadioOnlyPlayback: true,
respectTrustedUserSelection: true,
/* 마스크는 실제 화질 전환 직전에만 적용합니다. */
visualMaskEnabled: true,
visualMaskColor: '#000',
visualMaskFadeMs: 160,
stableFrameWaitMs: 1800,
revealDelayMs: 40,
switchMaskMaxMs: 3000,
/* Greasy Fork 배포본 기본값. 문제 확인 시 true로 바꾸면 됩니다. */
debug: false
};
const state = {
version: VERSION,
channelId: '',
generation: 0,
startedAt: 0,
resetPerfNow: 0,
deadlineAt: 0,
freshPlaybackSeen: false,
freshResourceAtPerf: 0,
freshResourceKind: '',
resourceQuality: '',
targetResourceAtPerf: 0,
controllerFound: false,
controllerCandidateCount: 0,
selectedControllerTrackCount: 0,
targetStableKey: '',
targetStableCount: 0,
staleHighestIgnored: 0,
playbackMode: 'unknown',
radioOnlyDetected: false,
radioCandidateSince: 0,
radioEvidenceCount: 0,
videoFound: false,
playbackStarted: false,
decodedVideoWidth: 0,
decodedVideoHeight: 0,
source: '',
availableFixedQualities: [],
target: null,
selectedBefore: null,
selectedAfter: null,
applyAttempts: 0,
done: false,
doneReason: '',
userSelectedManually: false,
videoWasPlayingBeforeSwitch: false,
videoPausedAfterSwitch: false,
resumeAttempts: 0,
resumeResult: '',
visualMaskArmed: false,
visualMaskReleased: false,
visualMaskReason: '',
visualMaskArmedAt: 0,
visualMaskReleasedAt: 0,
switchStartedAtPerf: 0,
targetSelectedAtPerf: 0,
targetFrameReadyAtPerf: 0,
lastError: ''
};
let pollTimer = null;
let routeTimer = null;
let perfObserver = null;
let maskFailOpenTimer = null;
let applying = false;
let polling = false;
let pollQueued = false;
let clickListenerInstalled = false;
const observedVideos = new WeakSet();
const MASK_ATTR = 'data-chzzk-initial-quality-mask';
const MASK_STYLE_ID = 'chzzk-initial-quality-mask-style';
function log(...args) {
if (CONFIG.debug) console.log(`🟢 [${NAME}]`, ...args);
}
function warn(...args) {
console.warn(`🟡 [${NAME}]`, ...args);
}
function recordError(error) {
state.lastError = String(error && (error.stack || error.message) || error);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getChannelId() {
return location.pathname.match(/^\/live\/([^/?#]+)/)?.[1] || '';
}
function isLivePath() {
return /^\/live\/[^/?#]+/.test(location.pathname);
}
function textOf(el) {
try {
return String(el?.innerText ?? el?.textContent ?? '')
.replace(/\s+/g, ' ')
.trim();
} catch (_) {
return '';
}
}
function schedulePoll() {
if (pollQueued || state.done || !isLivePath()) return;
pollQueued = true;
queueMicrotask(() => {
pollQueued = false;
void poll();
});
}
function ensureVisualMaskStyle() {
if (!CONFIG.visualMaskEnabled) return;
if (document.getElementById(MASK_STYLE_ID)) return;
const style = document.createElement('style');
style.id = MASK_STYLE_ID;
style.textContent = `
#live_player_layout,
#live_player_layout .pzp {
background: ${CONFIG.visualMaskColor} !important;
}
html[${MASK_ATTR}="pending"] #live_player_layout .pzp-pc__video {
opacity: 0 !important;
}
html[${MASK_ATTR}="pending"] #live_player_layout .pzp-pc__video,
html[${MASK_ATTR}="revealing"] #live_player_layout .pzp-pc__video {
transition: opacity ${CONFIG.visualMaskFadeMs}ms ease-out !important;
}
html[${MASK_ATTR}="revealing"] #live_player_layout .pzp-pc__video {
opacity: 1 !important;
}
`;
const append = () => {
if (style.isConnected) return true;
const root = document.head || document.documentElement;
if (!root) return false;
root.appendChild(style);
return true;
};
if (!append()) {
const observer = new MutationObserver(() => {
if (append()) observer.disconnect();
});
observer.observe(document, { childList: true, subtree: true });
}
}
function forceClearVisualMask(reason = 'force-clear') {
if (maskFailOpenTimer) {
clearTimeout(maskFailOpenTimer);
maskFailOpenTimer = null;
}
document.documentElement?.removeAttribute(MASK_ATTR);
if (state.visualMaskArmed && !state.visualMaskReleased) {
state.visualMaskReleased = true;
state.visualMaskReason = reason;
state.visualMaskReleasedAt = Date.now();
}
}
function armVisualMask(reason, generation = state.generation) {
if (!CONFIG.visualMaskEnabled) return;
ensureVisualMaskStyle();
const html = document.documentElement;
if (!html) return;
if (maskFailOpenTimer) clearTimeout(maskFailOpenTimer);
html.setAttribute(MASK_ATTR, 'pending');
state.visualMaskArmed = true;
state.visualMaskReleased = false;
state.visualMaskReason = reason;
state.visualMaskArmedAt = Date.now();
state.visualMaskReleasedAt = 0;
maskFailOpenTimer = setTimeout(() => {
if (
generation === state.generation &&
state.visualMaskArmed &&
!state.visualMaskReleased
) {
releaseVisualMask('switch-mask-timeout', true);
}
}, CONFIG.switchMaskMaxMs);
}
function releaseVisualMask(reason, immediate = false) {
if (!CONFIG.visualMaskEnabled) return;
if (!state.visualMaskArmed || state.visualMaskReleased) return;
state.visualMaskReleased = true;
state.visualMaskReason = reason;
state.visualMaskReleasedAt = Date.now();
if (maskFailOpenTimer) {
clearTimeout(maskFailOpenTimer);
maskFailOpenTimer = null;
}
const html = document.documentElement;
if (!html) return;
if (immediate || CONFIG.visualMaskFadeMs <= 0) {
html.removeAttribute(MASK_ATTR);
return;
}
html.setAttribute(MASK_ATTR, 'revealing');
setTimeout(() => {
if (document.documentElement?.getAttribute(MASK_ATTR) === 'revealing') {
document.documentElement.removeAttribute(MASK_ATTR);
}
}, CONFIG.visualMaskFadeMs + 80);
}
function parseQuality(value) {
const match = String(value || '').match(/\b(\d{3,4})p\b/i);
if (!match) return null;
const height = Number(match[1]);
if (!Number.isFinite(height)) return null;
return { label: `${height}p`, height };
}
function isAbrLike(value) {
const text = String(value || '').trim().toLowerCase();
return text === 'abr' || text.includes('자동');
}
function summarizeTrack(track) {
if (!track || typeof track !== 'object') return null;
return {
id: track.id ?? null,
label: track.label ?? '',
quality: track.quality ?? '',
width: Number(track.width ?? track.videoWidth ?? 0) || 0,
height: Number(track.height ?? track.videoHeight ?? 0) || 0,
videoBitRate: Number(
track.videoBitRate ?? track.bitrate ?? track.bandwidth ?? 0
) || 0,
selected: !!track.selected,
checked: !!track.checked,
avoidReencoding: !!track.dataset?.avoidReencoding || !!track.avoidReencoding,
audioOnly: !!track.audioOnly
};
}
function isFixedNumericTrack(item) {
if (!item || item.id == null || item.audioOnly) return false;
if (isAbrLike(item.id) || isAbrLike(item.label) || isAbrLike(item.quality)) {
return false;
}
const parsed = parseQuality(item.quality) || parseQuality(item.label);
if (!parsed && !item.height) return false;
return !/audio|audioonly|radio/i.test(
`${item.id || ''} ${item.label || ''} ${item.quality || ''}`
);
}
function getCandidateScore(item) {
const parsed = parseQuality(item.quality) || parseQuality(item.label);
const height = Number(item.height || parsed?.height || 0);
const width = Number(item.width || 0);
const bitrate = Number(item.videoBitRate || 0);
return (
height * 1_000_000_000 +
width * 1_000_000 +
bitrate +
(item.avoidReencoding ? 100_000 : 0)
);
}
function findQualityControllers() {
const controllers = [];
const seen = new WeakSet();
const add = (vm) => {
if (!vm || typeof vm !== 'object' || seen.has(vm)) return;
if (vm.$el instanceof Element && !vm.$el.isConnected) return;
if (
typeof vm.getVideoTracksList === 'function' ||
typeof vm.formattedVideoTracks === 'function' ||
typeof vm.selectVideoTrack === 'function'
) {
seen.add(vm);
controllers.push(vm);
}
};
for (const el of document.querySelectorAll(
'#live_player_layout .pzp-setting-quality-pane,' +
'#live_player_layout li.pzp-ui-setting-quality-item,' +
'#live_player_layout .pzp-setting-intro-quality'
)) {
let vm = el.__vue__;
for (let depth = 0; vm && depth < 8; depth++, vm = vm.$parent) add(vm);
}
const rootVm = document.querySelector('#live_player_layout .pzp')?.__vue__;
if (rootVm) {
const queue = [rootVm];
const traversed = new WeakSet();
let visited = 0;
while (queue.length && visited < 120) {
const vm = queue.shift();
if (!vm || typeof vm !== 'object' || traversed.has(vm)) continue;
traversed.add(vm);
visited++;
add(vm);
for (const child of vm.$children || []) queue.push(child);
}
}
return controllers;
}
function getTrackModels(controller) {
const rawMap = new Map();
const rows = [];
try {
const raw = controller?.getVideoTracksList?.();
if (Array.isArray(raw)) {
for (const track of raw) {
const summary = summarizeTrack(track);
if (summary?.id != null) rawMap.set(String(summary.id), summary);
}
}
} catch (error) {
recordError(error);
}
try {
const formatted = controller?.formattedVideoTracks?.();
if (Array.isArray(formatted)) {
for (const item of formatted) {
const id = item?.id ?? null;
const raw = id != null ? rawMap.get(String(id)) : null;
rows.push({
...(raw || {}),
id,
label: item?.quality ?? raw?.label ?? '',
quality: item?.quality ?? raw?.quality ?? '',
width: Number(raw?.width ?? item?.width ?? 0) || 0,
height: Number(raw?.height ?? item?.height ?? 0) || 0,
videoBitRate: Number(raw?.videoBitRate ?? item?.videoBitRate ?? 0) || 0,
selected: !!(item?.selected || raw?.selected),
checked: !!(item?.checked || raw?.checked),
avoidReencoding: !!(
raw?.avoidReencoding ||
item?.avoidReencoding ||
/\(원본\)|passthrough/i.test(String(item?.passthrough || ''))
),
source: 'formattedVideoTracks'
});
}
}
} catch (error) {
recordError(error);
}
if (!rows.length) {
for (const raw of rawMap.values()) {
rows.push({ ...raw, source: 'getVideoTracksList' });
}
}
return rows;
}
function analyzeQualityControllers() {
const candidates = findQualityControllers()
.map((controller) => {
const rows = getTrackModels(controller);
const fixedRows = rows.filter(isFixedNumericTrack);
const maxFixedHeight = fixedRows.reduce((max, item) => {
const parsed = parseQuality(item.quality) || parseQuality(item.label);
return Math.max(max, Number(item.height || parsed?.height || 0));
}, 0);
const abrRows = rows.filter((item) => (
isAbrLike(item.id) || isAbrLike(item.label) || isAbrLike(item.quality)
));
const elementText = textOf(controller?.$el);
const score =
fixedRows.length * 1_000_000_000 +
maxFixedHeight * 1_000_000 +
rows.length * 10_000 +
(/1080p|720p|480p|360p/i.test(elementText) ? 1_000 : 0);
return { controller, rows, fixedRows, abrRows, maxFixedHeight, score };
})
.sort((a, b) => b.score - a.score);
const withFixed = candidates.find((item) => (
item.fixedRows.length > 0 &&
typeof item.controller.selectVideoTrack === 'function'
)) || null;
state.controllerCandidateCount = candidates.length;
state.selectedControllerTrackCount = withFixed?.rows.length || 0;
return {
candidates,
withFixed,
totalFixedTracks: candidates.reduce((sum, item) => sum + item.fixedRows.length, 0)
};
}
function chooseHighestFixedTrack(rows) {
const fixed = rows
.filter(isFixedNumericTrack)
.map((item) => ({
...item,
parsed: parseQuality(item.quality) || parseQuality(item.label)
}));
state.availableFixedQualities = fixed
.map((item) => ({
id: item.id,
label: item.parsed?.label || item.quality || item.label,
width: item.width,
height: item.height || item.parsed?.height || 0,
selected: item.selected || item.checked,
source: item.source
}))
.sort((a, b) => b.height - a.height || b.width - a.width);
return fixed.sort((a, b) => getCandidateScore(b) - getCandidateScore(a))[0] || null;
}
function getSelectedTrack(rows) {
return rows.find((item) => item.selected || item.checked) || null;
}
function summarizeSelected(track) {
if (!track) return null;
return {
id: track.id,
label:
parseQuality(track.quality)?.label ||
parseQuality(track.label)?.label ||
track.quality ||
track.label
};
}
function selectedMatchesTarget(controller, target) {
const selected = getSelectedTrack(getTrackModels(controller));
state.selectedAfter = summarizeSelected(selected);
return !!(selected && String(selected.id) === String(target.id));
}
function updateTargetStability(controller, target, rows) {
const key = [
state.channelId,
target.id,
...rows.map((item) => item.id).sort()
].join('|');
if (key === state.targetStableKey) {
state.targetStableCount++;
} else {
state.targetStableKey = key;
state.targetStableCount = 1;
}
return state.targetStableCount >= CONFIG.targetStabilityPolls;
}
function findVideo() {
return document.querySelector(
'#live_player_layout video.webplayer-internal-video,' +
'#live_player_layout video'
);
}
function hasPlaybackStarted(video) {
return !!(
video instanceof HTMLVideoElement &&
!video.paused &&
!video.ended &&
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
}
function attachVideoEvents(video) {
if (!(video instanceof HTMLVideoElement) || observedVideos.has(video)) return;
observedVideos.add(video);
for (const type of ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'pause', 'resize']) {
video.addEventListener(type, schedulePoll, { passive: true });
}
}
function getTargetLabel(target) {
return (
target?.parsed?.label ||
parseQuality(target?.quality)?.label ||
parseQuality(target?.label)?.label ||
''
);
}
function targetFrameEvidence(controller, target, video) {
if (!(video instanceof HTMLVideoElement)) return false;
const targetLabel = getTargetLabel(target);
const targetHeight = Number(target?.height || target?.parsed?.height || 0);
const selected = selectedMatchesTarget(controller, target);
const numericResource = !!(
targetLabel &&
state.resourceQuality.toLowerCase() === targetLabel.toLowerCase()
);
const decodedHeight = Number(video.videoHeight || 0);
const decodedTarget = !!(
targetHeight > 0 &&
decodedHeight >= Math.max(1, targetHeight - 8)
);
const switchedLongEnough = !!(
state.switchStartedAtPerf &&
performance.now() - state.switchStartedAtPerf >= 250
);
return !!(
hasPlaybackStarted(video) &&
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
(
numericResource ||
(decodedTarget && switchedLongEnough && (
state.freshResourceKind !== 'opaque-stream' || selected
))
)
);
}
async function waitForCondition(test, timeoutMs, intervalMs = 80, generation = state.generation) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (generation !== state.generation) return false;
try {
if (test()) return true;
} catch (_) {}
await sleep(intervalMs);
}
return false;
}
async function waitForStablePlayback(video, stableMs, timeoutMs, generation) {
const started = Date.now();
let stableSince = 0;
while (Date.now() - started < timeoutMs) {
if (generation !== state.generation) return false;
if (hasPlaybackStarted(video)) {
if (!stableSince) stableSince = Date.now();
if (Date.now() - stableSince >= stableMs) return true;
} else {
stableSince = 0;
}
await sleep(50);
}
return false;
}
async function waitForOneVideoFrame(video, generation) {
if (generation !== state.generation) return;
if (typeof video.requestVideoFrameCallback !== 'function') {
await sleep(80);
return;
}
await Promise.race([
new Promise((resolve) => video.requestVideoFrameCallback(() => resolve())),
sleep(350)
]);
}
async function revealWhenTargetFrameReady(controller, target, video, generation) {
if (!state.visualMaskArmed || state.visualMaskReleased) return false;
const ready = await waitForCondition(
() => targetFrameEvidence(controller, target, video),
CONFIG.stableFrameWaitMs,
50,
generation
);
if (generation !== state.generation) return false;
if (ready) {
await waitForOneVideoFrame(video, generation);
if (CONFIG.revealDelayMs > 0) await sleep(CONFIG.revealDelayMs);
state.targetFrameReadyAtPerf = performance.now();
releaseVisualMask('target-frame-ready');
return true;
}
releaseVisualMask('target-frame-wait-timeout');
return false;
}
function looksLikeRadioOnlyPlayer(analysis) {
const playerText = textOf(document.querySelector('#live_player_layout .pzp'));
const explicitRadioUi =
/라디오\s*모드로\s*재생\s*중|영상도\s*함께\s*보려면|멤버십을\s*시작|치트키\s*이용자도\s*시청\s*가능/i
.test(playerText);
return !!(
CONFIG.bypassRadioOnlyPlayback &&
state.freshPlaybackSeen &&
state.playbackStarted &&
analysis.totalFixedTracks === 0 &&
(explicitRadioUi || state.freshResourceKind === 'audio-only')
);
}
function confirmRadioOnly(evidence) {
if (!evidence) {
state.radioCandidateSince = 0;
state.radioEvidenceCount = 0;
return false;
}
state.radioEvidenceCount++;
if (!state.radioCandidateSince) state.radioCandidateSince = Date.now();
return !!(
state.radioEvidenceCount >= 2 &&
Date.now() - state.radioCandidateSince >= CONFIG.radioConfirmMs
);
}
function finish(reason) {
if (state.done) return;
if (state.visualMaskArmed && !state.visualMaskReleased) {
releaseVisualMask(`finish:${reason}`, !reason.startsWith('highest-fixed'));
}
state.done = true;
state.doneReason = reason;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
try {
perfObserver?.disconnect();
} catch (_) {}
perfObserver = null;
log('완료:', reason, state.target || '');
}
async function restorePlaybackIfNeeded(controller, video, generation) {
const naturallyStable = await waitForStablePlayback(
video,
160,
CONFIG.passiveResumeWaitMs,
generation
);
if (naturallyStable) {
state.resumeResult = 'continued-or-resumed-naturally';
return true;
}
state.videoPausedAfterSwitch = !!video.paused;
if (!state.videoWasPlayingBeforeSwitch) {
state.resumeResult = 'not-restored-because-playback-had-not-started';
return false;
}
state.resumeAttempts++;
try {
await Promise.resolve(controller?.$store?.dispatch?.('play'));
} catch (error) {
recordError(error);
}
let resumed = await waitForStablePlayback(video, 140, 650, generation);
if (!resumed) {
state.resumeAttempts++;
try {
await video.play();
} catch (error) {
recordError(error);
}
resumed = await waitForStablePlayback(
video,
140,
CONFIG.resumeVerifyMs,
generation
);
}
state.resumeResult = resumed ? 'restored-after-track-switch' : 'resume-failed';
return resumed;
}
async function verifySelection(controller, target, video, generation) {
const targetLabel = getTargetLabel(target);
return waitForCondition(() => {
if (selectedMatchesTarget(controller, target)) {
state.targetSelectedAtPerf ||= performance.now();
return true;
}
if (
targetLabel &&
state.resourceQuality.toLowerCase() === targetLabel.toLowerCase()
) {
state.targetResourceAtPerf ||= performance.now();
return true;
}
return targetFrameEvidence(controller, target, video);
}, CONFIG.selectionVerifyMs, 100, generation);
}
async function applyHighestTrack(controller, target, video, generation) {
if (
applying ||
state.done ||
state.userSelectedManually ||
generation !== state.generation
) {
return;
}
applying = true;
state.applyAttempts++;
try {
const rows = getTrackModels(controller);
state.selectedBefore = summarizeSelected(getSelectedTrack(rows));
state.videoWasPlayingBeforeSwitch = hasPlaybackStarted(video);
state.switchStartedAtPerf = performance.now();
armVisualMask('quality-switch', generation);
log(
`재생 시작 후 내부 API 선택 시도 ${state.applyAttempts}/${CONFIG.maxApplyAttempts}:`,
getTargetLabel(target) || String(target.id),
target.id
);
await Promise.resolve(controller.selectVideoTrack(target.id));
if (generation !== state.generation) return;
state.source = 'controller.selectVideoTrack';
const selected = await verifySelection(controller, target, video, generation);
if (generation !== state.generation) return;
if (!selected) {
releaseVisualMask('selection-not-confirmed', true);
if (state.applyAttempts >= CONFIG.maxApplyAttempts) {
finish('selection-verification-failed');
}
return;
}
const playing = await restorePlaybackIfNeeded(controller, video, generation);
if (generation !== state.generation) return;
const frameReady = await revealWhenTargetFrameReady(
controller,
target,
video,
generation
);
if (generation !== state.generation) return;
if (!playing) {
finish('highest-fixed-selected-but-playback-paused');
} else if (state.resumeResult === 'restored-after-track-switch') {
finish(frameReady
? 'highest-fixed-selected-playback-restored'
: 'highest-fixed-selected-playback-restored-frame-unconfirmed');
} else {
finish(frameReady
? 'highest-fixed-selected-playing'
: 'highest-fixed-selected-playing-frame-unconfirmed');
}
} catch (error) {
if (generation !== state.generation) return;
recordError(error);
releaseVisualMask('internal-api-error', true);
warn('내부 품질 API 호출 실패:', error);
if (state.applyAttempts >= CONFIG.maxApplyAttempts) {
finish('internal-api-error');
}
} finally {
if (generation === state.generation) applying = false;
}
}
async function poll() {
if (polling || applying || state.done || state.userSelectedManually) return;
polling = true;
const generation = state.generation;
try {
if (!isLivePath()) {
finish('left-live-page');
return;
}
if (Date.now() > state.deadlineAt) {
finish('controller-or-playback-timeout');
return;
}
const video = findVideo();
if (video) {
attachVideoEvents(video);
state.videoFound = true;
state.playbackStarted = hasPlaybackStarted(video);
state.decodedVideoWidth = Number(video.videoWidth || 0);
state.decodedVideoHeight = Number(video.videoHeight || 0);
}
const analysis = analyzeQualityControllers();
state.controllerFound = analysis.candidates.length > 0;
if (confirmRadioOnly(looksLikeRadioOnlyPlayer(analysis))) {
state.radioOnlyDetected = true;
state.playbackMode = 'radio-only';
forceClearVisualMask('radio-only-pass-through');
finish('radio-only-pass-through');
return;
}
const candidate = analysis.withFixed;
if (!candidate || !video) return;
const controller = candidate.controller;
const rows = candidate.rows;
const target = chooseHighestFixedTrack(rows);
if (!target) return;
state.playbackMode = 'video';
state.target = {
id: target.id,
label: getTargetLabel(target) || target.quality || target.label,
width: target.width,
height: target.height || target.parsed?.height || 0,
source: target.source
};
if (!updateTargetStability(controller, target, rows)) return;
if (CONFIG.requireFreshChannelResource && !state.freshPlaybackSeen) return;
if (
state.freshResourceAtPerf &&
performance.now() - state.freshResourceAtPerf < CONFIG.freshResourceSettleMs
) {
return;
}
if (!state.playbackStarted) return;
const selected = getSelectedTrack(rows);
const selectedIsTarget = !!(
selected && String(selected.id) === String(target.id)
);
const targetLabel = getTargetLabel(target);
const numericResourceIsTarget = !!(
targetLabel &&
state.resourceQuality.toLowerCase() === targetLabel.toLowerCase()
);
if (numericResourceIsTarget) {
state.selectedBefore = summarizeSelected(selected);
state.selectedAfter = summarizeSelected(selected);
state.targetSelectedAtPerf ||= performance.now();
state.targetFrameReadyAtPerf ||= performance.now();
finish('already-highest-fixed');
return;
}
if (selectedIsTarget) {
state.staleHighestIgnored++;
log(
'선택값은 최고화질이지만 실제 프레임이 확인되지 않아 내부 API를 재적용:',
state.resourceQuality || state.freshResourceKind || 'unknown'
);
}
if (state.applyAttempts < CONFIG.maxApplyAttempts) {
void applyHighestTrack(controller, target, video, generation);
}
} finally {
polling = false;
}
}
function installResourceObserver() {
if (typeof PerformanceObserver === 'undefined') return;
const generation = state.generation;
const resetPerfNow = state.resetPerfNow;
try {
perfObserver = new PerformanceObserver((list) => {
if (generation !== state.generation) return;
for (const entry of list.getEntries()) {
if (Number(entry.startTime || 0) + 1 < resetPerfNow) continue;
const name = String(entry.name || '');
const isStreamingResource =
/\.(?:m3u8|m4s|m4v|ts)(?:\?|$)/i.test(name) &&
/(?:livecloud|nlive|nvelop|navercdn|pstatic)/i.test(name);
if (!isStreamingResource) continue;
const numericMatch = name.match(
/\/(2160p|1440p|1080p|720p|480p|360p|144p)\//i
);
const audioOnly = /audioOnly|audio_only|\/radio(?:\/|_|-)/i.test(name);
if (numericMatch) {
state.resourceQuality = numericMatch[1];
state.freshResourceKind = 'numeric-video';
if (
state.target?.label &&
state.target.label.toLowerCase() === numericMatch[1].toLowerCase()
) {
state.targetResourceAtPerf ||= performance.now();
}
} else if (audioOnly) {
state.resourceQuality = 'audioOnly';
state.freshResourceKind = 'audio-only';
} else if (CONFIG.acceptOpaqueStreamResource) {
state.freshResourceKind = 'opaque-stream';
} else {
continue;
}
if (!state.freshPlaybackSeen) {
state.freshPlaybackSeen = true;
state.freshResourceAtPerf = performance.now();
}
}
schedulePoll();
});
perfObserver.observe({ type: 'resource', buffered: true });
} catch (error) {
recordError(error);
}
}
function installTrustedClickCancel() {
if (clickListenerInstalled || !CONFIG.respectTrustedUserSelection) return;
document.addEventListener('click', (event) => {
if (!event.isTrusted || state.done) return;
const target = event.target instanceof Element
? event.target.closest(
'#live_player_layout li.pzp-ui-setting-quality-item,' +
'#live_player_layout .pzp-setting-quality-pane [role="menuitem"]'
)
: null;
if (!target) return;
if (/\b\d{3,4}p\b|자동|abr/i.test(textOf(target))) {
state.userSelectedManually = true;
forceClearVisualMask('user-selected-quality-manually');
finish('user-selected-quality-manually');
}
}, true);
clickListenerInstalled = true;
}
function resetForChannel(reason) {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
try {
perfObserver?.disconnect();
} catch (_) {}
perfObserver = null;
forceClearVisualMask('channel-reset');
applying = false;
polling = false;
pollQueued = false;
state.generation++;
state.channelId = getChannelId();
state.startedAt = Date.now();
state.resetPerfNow = performance.now();
state.deadlineAt = state.startedAt + CONFIG.maxWaitMs;
state.freshPlaybackSeen = false;
state.freshResourceAtPerf = 0;
state.freshResourceKind = '';
state.resourceQuality = '';
state.targetResourceAtPerf = 0;
state.controllerFound = false;
state.controllerCandidateCount = 0;
state.selectedControllerTrackCount = 0;
state.targetStableKey = '';
state.targetStableCount = 0;
state.staleHighestIgnored = 0;
state.playbackMode = 'unknown';
state.radioOnlyDetected = false;
state.radioCandidateSince = 0;
state.radioEvidenceCount = 0;
state.videoFound = false;
state.playbackStarted = false;
state.decodedVideoWidth = 0;
state.decodedVideoHeight = 0;
state.source = '';
state.availableFixedQualities = [];
state.target = null;
state.selectedBefore = null;
state.selectedAfter = null;
state.applyAttempts = 0;
state.done = false;
state.doneReason = '';
state.userSelectedManually = false;
state.videoWasPlayingBeforeSwitch = false;
state.videoPausedAfterSwitch = false;
state.resumeAttempts = 0;
state.resumeResult = '';
state.visualMaskArmed = false;
state.visualMaskReleased = false;
state.visualMaskReason = '';
state.visualMaskArmedAt = 0;
state.visualMaskReleasedAt = 0;
state.switchStartedAtPerf = 0;
state.targetSelectedAtPerf = 0;
state.targetFrameReadyAtPerf = 0;
state.lastError = '';
installResourceObserver();
pollTimer = setInterval(schedulePoll, CONFIG.pollIntervalMs);
schedulePoll();
log('채널 초기화:', reason, state.channelId, `generation=${state.generation}`);
}
function watchRouteChanges() {
let lastPath = location.pathname;
const check = (reason) => {
if (location.pathname === lastPath) return;
lastPath = location.pathname;
if (isLivePath()) {
resetForChannel('spa-route-change');
} else if (!state.done) {
forceClearVisualMask('left-live-page');
finish('left-live-page');
}
};
for (const methodName of ['pushState', 'replaceState']) {
const original = history[methodName];
if (typeof original !== 'function' || original.__chzzkQualityRouteWrapped) {
continue;
}
function wrappedHistoryMethod(...args) {
const result = original.apply(this, args);
queueMicrotask(() => check(`history.${methodName}`));
return result;
}
Object.defineProperty(wrappedHistoryMethod, '__chzzkQualityRouteWrapped', {
value: true
});
history[methodName] = wrappedHistoryMethod;
}
window.addEventListener('popstate', () => check('popstate'));
routeTimer = setInterval(() => check('interval'), CONFIG.routeCheckMs);
}
function diag() {
const now = performance.now();
return {
name: NAME,
version: VERSION,
href: location.href,
channelId: state.channelId,
generation: state.generation,
freshPlaybackSeen: state.freshPlaybackSeen,
freshResourceAtPerf: state.freshResourceAtPerf,
freshResourceKind: state.freshResourceKind,
resourceQuality: state.resourceQuality,
targetResourceAtPerf: state.targetResourceAtPerf,
controllerFound: state.controllerFound,
controllerCandidateCount: state.controllerCandidateCount,
selectedControllerTrackCount: state.selectedControllerTrackCount,
targetStableCount: state.targetStableCount,
staleHighestIgnored: state.staleHighestIgnored,
playbackMode: state.playbackMode,
radioOnlyDetected: state.radioOnlyDetected,
videoFound: state.videoFound,
playbackStarted: state.playbackStarted,
decodedVideoWidth: state.decodedVideoWidth,
decodedVideoHeight: state.decodedVideoHeight,
source: state.source,
availableFixedQualities: state.availableFixedQualities,
target: state.target,
selectedBefore: state.selectedBefore,
selectedAfter: state.selectedAfter,
videoWasPlayingBeforeSwitch: state.videoWasPlayingBeforeSwitch,
videoPausedAfterSwitch: state.videoPausedAfterSwitch,
resumeAttempts: state.resumeAttempts,
resumeResult: state.resumeResult,
visualMaskArmed: state.visualMaskArmed,
visualMaskReleased: state.visualMaskReleased,
visualMaskReason: state.visualMaskReason,
visualMaskDurationMs:
state.visualMaskArmedAt && state.visualMaskReleasedAt
? state.visualMaskReleasedAt - state.visualMaskArmedAt
: 0,
switchStartedAtPerf: state.switchStartedAtPerf,
targetSelectedAtPerf: state.targetSelectedAtPerf,
targetFrameReadyAtPerf: state.targetFrameReadyAtPerf,
switchToVisibleMs:
state.switchStartedAtPerf && state.visualMaskReleasedAt
? Math.max(0, state.visualMaskReleasedAt - (performance.timeOrigin + state.switchStartedAtPerf))
: 0,
applyAttempts: state.applyAttempts,
done: state.done,
doneReason: state.doneReason,
userSelectedManually: state.userSelectedManually,
remainingWaitMs: Math.max(0, state.deadlineAt - Date.now()),
elapsedPerfMs: Math.round(now),
lastError: state.lastError
};
}
function init() {
ensureVisualMaskStyle();
installTrustedClickCancel();
watchRouteChanges();
window.__CHZZK_INITIAL_QUALITY__ = {
version: VERSION,
config: CONFIG,
diag,
retry() {
if (!isLivePath()) return false;
resetForChannel('manual-retry');
return true;
},
stop() {
forceClearVisualMask('stopped-manually');
finish('stopped-manually');
return true;
}
};
if (isLivePath()) resetForChannel('initial-load');
log(`v${VERSION} loaded`);
}
init();
})();