957 lines
49 KiB
JavaScript
957 lines
49 KiB
JavaScript
(() => {
|
||
const node = id => document.getElementById(id);
|
||
const panel = node('playerPanel');
|
||
const video = node('onlineVideo');
|
||
const episodeList = node('playbackEpisodes');
|
||
const statusText = node('playbackStatus');
|
||
const errorText = node('playbackError');
|
||
const qualityControl = node('playbackQualityControl');
|
||
const qualitySelect = node('playbackQuality');
|
||
let quality = 0;
|
||
let transport = 'mse';
|
||
let allowRemux = false, streamRemux = false;
|
||
let mediaPlan = null, planMode = 'auto', planKey = '', planFallbacks = 0, networkRefreshes = 0;
|
||
try {quality = Number(localStorage.getItem('juku.playback.quality')) || 0;} catch (_) {}
|
||
if (!Number.isInteger(quality) || quality < 0 || quality > 4320) quality = 0;
|
||
let dramaID = '';
|
||
let dramaName = '';
|
||
let collectionTaskID = '';
|
||
let collectionMode = false;
|
||
let preparedIndex = 0;
|
||
let sessionID = '';
|
||
let sessionAvailable = false;
|
||
let mimeType = '';
|
||
let episodes = [];
|
||
let episodeButtons = [];
|
||
let episodePage = 0, prefetchStatusTimer = null, prefetchPreparing = false;
|
||
const completedEpisodes = new Set();
|
||
let currentIndex = 0;
|
||
let openingVersion = 0;
|
||
let streamVersion = 0;
|
||
let openingController = null;
|
||
let streamController = null;
|
||
let objectURL = '';
|
||
let loading = false;
|
||
let recoveryPromise = null, recoveryTarget = null, recoveryBlocked = false;
|
||
let recoveryAttempts = [];
|
||
let seekTimer = null;
|
||
let lastPosition = 0;
|
||
let playbackRun = 0;
|
||
let streamComplete = false;
|
||
let prefetchAttempted = 0;
|
||
let prefetchVersion = 0;
|
||
let historySource = '', releaseStatus = '';
|
||
let historySequence = 0;
|
||
let historyPlayed = false;
|
||
let historyCompleted = false;
|
||
let historySentAt = 0;
|
||
let historySignature = '';
|
||
let playbackDuration = 0;
|
||
let historyMessage = '';
|
||
const historyStatus = node('playbackHistoryStatus');
|
||
const prefetchToggle = node('prefetchNextEpisode');
|
||
const prefetchStatus = node('prefetchStatus');
|
||
try {prefetchToggle.checked = localStorage.getItem('juku.playback.prefetchNext') !== 'false';} catch (_) {}
|
||
const mobile = window.JukuPlayerMobile({panel, video, state: () => ({title: dramaName, merged: episodes[currentIndex - 1]?.merged, episode: episodes[currentIndex - 1]?.episode, total: episodes.length, duration: playbackDuration, loading, releaseStatus, vip: episodes[currentIndex - 1]?.vip}), showEpisodes: showEpisodePanel, showPreferences: section => setPlayerPreferences(true, section)});
|
||
const controls = window.JukuPlayerControls({panel, video, mobile: () => mobile.active(), rotation: () => mobile.rotation(), canSwipe: () => panel.open && episodes.length > 0 && !panel.classList.contains('episodes-open') && node('playerPreferences').hidden, swipe: direction => {
|
||
const next = currentIndex + direction;
|
||
if (next < 1) return '已经是第一集';
|
||
if (next > episodes.length) return '已经是最后一集';
|
||
playEpisode(next);
|
||
return '第 ' + episodes[next - 1].episode + ' 集';
|
||
}, ready: () => !loading && Boolean(currentIndex), fullscreen: () => {
|
||
if (document.fullscreenElement === node('playbackStage') || document.webkitFullscreenElement === node('playbackStage')) node('exitPlayerFullscreenBtn').click();
|
||
else if (!node('playerFullscreenBtn').hidden) node('playerFullscreenBtn').click();
|
||
}});
|
||
|
||
function clear(element) {
|
||
while (element.firstChild) element.removeChild(element.firstChild);
|
||
}
|
||
|
||
const capabilities = window.JukuPlaybackCapabilities(window, video, navigator);
|
||
const supportsNativePlayback = capabilities.native;
|
||
const supportsMediaSource = capabilities.mse;
|
||
let pendingMediaFailure = null;
|
||
const mediaLoader = window.JukuPlayerMedia(window, video, async error => {
|
||
const version = streamVersion;
|
||
if (!mediaPlan || !panel.open) return;
|
||
if (!await handleMediaFailure(error, version)) showError(error);
|
||
});
|
||
|
||
function handleMediaFailure(error, version) {
|
||
if (pendingMediaFailure?.version === version) return pendingMediaFailure.promise;
|
||
const pending = {version};
|
||
pendingMediaFailure = pending;
|
||
pending.promise = (async () => {
|
||
const state = await heartbeat(true);
|
||
if (version !== streamVersion || !panel.open || !sessionAvailable || recoveryPromise) return true;
|
||
if (!state || state.mediaFailure) {error.playbackNetwork = true; error.playbackDecode = false;}
|
||
return fallbackPlanned(error);
|
||
})().finally(() => {if (pendingMediaFailure === pending) pendingMediaFailure = null;});
|
||
return pending.promise;
|
||
}
|
||
|
||
function fallbackPlanned(error) {
|
||
if (!mediaPlan || !sessionAvailable || !currentIndex) return false;
|
||
if (mediaPlan.delivery === 'redirect') planMode = 'proxy';
|
||
else if (error.playbackNetwork) {
|
||
if (networkRefreshes >= 1) return false;
|
||
networkRefreshes++;
|
||
planMode = 'proxy';
|
||
} else {
|
||
if (planFallbacks >= 2) return false;
|
||
planFallbacks++;
|
||
planMode = mediaPlan.processing === 'original' ? 'compatible' : 'legacy';
|
||
if (planMode === 'legacy') allowRemux = false;
|
||
}
|
||
const position = !loading && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition;
|
||
playEpisode(currentIndex, position, desiredPlayback, true);
|
||
return true;
|
||
}
|
||
let desiredPlayback = true;
|
||
const keepalive = window.JukuPlaybackSession({
|
||
request: (session, signal) => requestJSON('/api/ui/playback/control', {session, action: 'heartbeat'}, signal),
|
||
onState: state => {if (state.run === playbackRun) renderPrefetchStatus(state.prefetch);},
|
||
onExpired: () => {sessionAvailable = false; recoverPlayback();},
|
||
onError: error => {sessionAvailable = false; recoveryBlocked = true; showError(error);}
|
||
});
|
||
|
||
function fallbackPlayback(index, offset, shouldPlay, keepResumeMessage = true) {
|
||
if (transport === 'mse' && allowRemux && streamRemux) {
|
||
allowRemux = false;
|
||
playEpisode(index, offset, shouldPlay, keepResumeMessage);
|
||
return true;
|
||
}
|
||
if (transport !== 'mse' || !supportsNativePlayback()) return false;
|
||
transport = 'hls';
|
||
playEpisode(index, offset, shouldPlay, keepResumeMessage);
|
||
return true;
|
||
}
|
||
|
||
function updateQualities(options, current) {
|
||
clear(qualitySelect);
|
||
const automatic = document.createElement('option');
|
||
automatic.value = '0';
|
||
automatic.textContent = current > 0 ? '自动(' + current + 'p)' : '自动';
|
||
qualitySelect.appendChild(automatic);
|
||
const values = new Set();
|
||
for (const entry of Array.isArray(options) ? options : []) {
|
||
const value = Number(entry.value);
|
||
if (!Number.isInteger(value) || value <= 0 || value > 4320 || values.has(value)) continue;
|
||
values.add(value);
|
||
const option = document.createElement('option');
|
||
option.value = String(value);
|
||
option.textContent = value + 'p';
|
||
qualitySelect.appendChild(option);
|
||
}
|
||
qualitySelect.value = values.has(quality) ? String(quality) : '0';
|
||
qualityControl.hidden = values.size < 2;
|
||
qualitySelect.disabled = loading;
|
||
mobile.update();
|
||
}
|
||
|
||
async function requestJSON(path, body, signal) {
|
||
const controller = new AbortController();
|
||
const abort = () => controller.abort();
|
||
if (signal?.aborted) abort();
|
||
else signal?.addEventListener('abort', abort, {once: true});
|
||
let timedOut = false;
|
||
const timeout = setTimeout(() => {timedOut = true; controller.abort();}, 100000);
|
||
try {
|
||
const response = await fetch(path, {
|
||
method: body === undefined ? 'GET' : 'POST',
|
||
headers: {'Accept': 'application/json', 'Content-Type': 'application/json', ...window.JukuViewer?.headers()},
|
||
credentials: 'same-origin',
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: controller.signal
|
||
});
|
||
const result = await response.json();
|
||
window.JukuViewer?.checkResponse(result);
|
||
if (!response.ok) {
|
||
const error = new Error(result.error || 'HTTP ' + response.status);
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
return result;
|
||
} catch (error) {
|
||
if (timedOut) throw new Error('播放请求超时,请检查连接后重试');
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
signal?.removeEventListener('abort', abort);
|
||
}
|
||
}
|
||
|
||
function historySnapshot(force = false, repeat = false) {
|
||
if (!historyPlayed || !sessionID || !playbackRun || !episodes[currentIndex - 1] || !window.JukuHistory) return null;
|
||
const position = !loading && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition;
|
||
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : playbackDuration;
|
||
if (!Number.isFinite(position) || position < 0) return null;
|
||
const signature = playbackRun + ':' + position.toFixed(3) + ':' + historyCompleted;
|
||
if (!repeat && signature === historySignature || !force && Date.now() - historySentAt < 10000) return null;
|
||
historySignature = signature;
|
||
historySentAt = Date.now();
|
||
const episode = episodes[currentIndex - 1];
|
||
const progress = {run: playbackRun, sequence: ++historySequence, episode: currentIndex, position, duration: duration || 0, completed: historyCompleted};
|
||
const entry = {dramaId: dramaID, title: dramaName, source: historySource, chapterId: episode.chapterId || '', episode: episode.episode, index: episode.number || currentIndex, total: episode.total || episodes.length, position, duration: duration || 0, completed: historyCompleted, mode: collectionMode ? 'collection' : 'online', taskId: episode.taskId || '', watchedAt: new Date().toISOString()};
|
||
return {progress, entry};
|
||
}
|
||
|
||
function saveHistory(force = false) {
|
||
const snapshot = historySnapshot(force);
|
||
if (!snapshot) return;
|
||
const session = sessionID;
|
||
window.JukuHistory.save(session, snapshot.progress, snapshot.entry).then(() => {
|
||
if (session === sessionID && snapshot.progress.sequence === historySequence) historyStatus.textContent = historyMessage;
|
||
}).catch(error => {
|
||
if (session !== sessionID || snapshot.progress.sequence !== historySequence) return;
|
||
historySignature = '';
|
||
if (error.status === 410) {sessionAvailable = false; recoverPlayback(); return;}
|
||
historyStatus.textContent = '观看进度暂未保存:' + error.message;
|
||
});
|
||
}
|
||
|
||
function releaseSession(id, progress, unloading = false) {
|
||
if (!id) return;
|
||
const body = JSON.stringify({session: id, action: 'close', ...(progress ? {progress} : {})});
|
||
if (unloading && navigator.sendBeacon && navigator.sendBeacon('/api/ui/playback/control', new Blob([body], {type: 'application/json'}))) return;
|
||
fetch('/api/ui/playback/control', {method: 'POST', headers: {'Content-Type': 'application/json', ...window.JukuViewer?.headers()}, credentials: 'same-origin', body, keepalive: true}).then(response => response.json()).then(result => {
|
||
window.JukuViewer?.checkResponse(result);
|
||
if (progress && result.historyError) window.JukuHistory?.reportError(result.historyError);
|
||
}).catch(error => {if (progress && !unloading) window.JukuHistory?.reportError(error.message);});
|
||
}
|
||
|
||
function stopStream() {
|
||
mediaLoader.destroy();
|
||
mediaPlan = null;
|
||
controls.reset();
|
||
saveHistory(true);
|
||
historyPlayed = false;
|
||
historyCompleted = false;
|
||
historySignature = '';
|
||
playbackDuration = 0;
|
||
window.JukuPlaybackDanmaku?.suspend();
|
||
streamVersion++;
|
||
playbackRun = 0;
|
||
streamComplete = false;
|
||
prefetchAttempted = 0;
|
||
prefetchPreparing = false;
|
||
clearTimeout(prefetchStatusTimer);
|
||
prefetchVersion++;
|
||
prefetchStatus.hidden = true;
|
||
prefetchStatus.textContent = '';
|
||
loading = true;
|
||
qualityControl.hidden = true;
|
||
qualitySelect.disabled = true;
|
||
clearTimeout(seekTimer);
|
||
if (streamController) streamController.abort();
|
||
streamController = null;
|
||
video.pause();
|
||
video.removeAttribute('src');
|
||
video.load();
|
||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||
objectURL = '';
|
||
}
|
||
|
||
function dispose(unloading = false) {
|
||
const snapshot = historySnapshot(true, true);
|
||
if (snapshot) window.JukuHistory.remember(snapshot.entry);
|
||
historyPlayed = false;
|
||
openingVersion++;
|
||
if (openingController) openingController.abort();
|
||
openingController = null;
|
||
keepalive.stop();
|
||
recoveryPromise = null;
|
||
recoveryTarget = null;
|
||
stopStream();
|
||
releaseSession(sessionID, snapshot?.progress, unloading);
|
||
sessionID = '';
|
||
sessionAvailable = false;
|
||
preparedIndex = 0;
|
||
window.JukuPlaybackDanmaku?.close();
|
||
}
|
||
|
||
function updateEpisodeControls() {
|
||
node('previousEpisodeBtn').disabled = currentIndex <= 1;
|
||
node('nextEpisodeBtn').disabled = currentIndex < 1 || currentIndex >= episodes.length;
|
||
node('retryPlaybackBtn').disabled = !dramaID && !collectionTaskID;
|
||
node('retryPlaybackBtn').hidden = !errorText.textContent;
|
||
for (const button of episodeButtons) {
|
||
const index = Number(button.dataset.episodeIndex);
|
||
button.setAttribute('aria-current', String(index === currentIndex));
|
||
button.classList.toggle('watched', completedEpisodes.has(index));
|
||
}
|
||
node('playbackEpisodeCount').textContent = episodes[currentIndex - 1]?.merged ? episodes[currentIndex - 1].title : episodes.length ? '第 ' + (episodes[currentIndex - 1]?.episode || currentIndex || '—') + ' 集 / 共 ' + episodes.length + ' 集' : '正在获取分集';
|
||
node('jumpEpisode').max = episodes.length || 1;
|
||
node('jumpEpisode').disabled = !episodes.length;
|
||
node('jumpEpisodeBtn').disabled = !episodes.length;
|
||
node('currentEpisodeBtn').disabled = !currentIndex;
|
||
node('episodeRange').disabled = !episodes.length;
|
||
mobile.update();
|
||
}
|
||
|
||
function showError(error) {
|
||
controls.reset();
|
||
window.JukuPlaybackDanmaku?.suspend();
|
||
loading = false;
|
||
qualitySelect.disabled = false;
|
||
errorText.textContent = (error.message || String(error)) + ';可点击“重试播放”。';
|
||
statusText.textContent = '播放未完成';
|
||
if (error.status === 410) sessionAvailable = false;
|
||
updateEpisodeControls();
|
||
}
|
||
|
||
function updateDependency(state, text) {
|
||
if (!panel.open || !openingController || sessionID || errorText.textContent) return;
|
||
if (state.status === 'downloading' || state.status === 'verifying') statusText.textContent = text;
|
||
else if (state.status === 'ready') statusText.textContent = collectionMode ? '正在读取合集分集…' : '正在获取分集…';
|
||
}
|
||
|
||
function renderEpisodes(page = episodePage) {
|
||
episodePage = Math.max(0, Math.min(page, Math.ceil(episodes.length / 30) - 1));
|
||
clear(episodeList);
|
||
const range = node('episodeRange');
|
||
clear(range);
|
||
for (let start = 0; start < episodes.length; start += 30) {
|
||
const option = document.createElement('option');
|
||
option.value = String(start / 30);
|
||
option.textContent = (start + 1) + '–' + Math.min(episodes.length, start + 30) + ' 集';
|
||
range.appendChild(option);
|
||
}
|
||
range.value = String(episodePage);
|
||
episodeButtons = episodes.slice(episodePage * 30, episodePage * 30 + 30).map(episode => {
|
||
const button = document.createElement('button');
|
||
button.className = 'secondary';
|
||
button.textContent = episode.merged ? '全集' : episode.episode + (episode.vip ? ' VIP' : '');
|
||
button.classList.toggle('vip-episode', Boolean(episode.vip));
|
||
button.title = (episode.title || '第 ' + episode.episode + ' 集') + (episode.vip ? ' · VIP,仅试看' : '');
|
||
button.setAttribute('aria-label', episode.merged ? '播放' + episode.title : '播放第 ' + episode.episode + ' 集' + (episode.vip ? ',VIP,仅试看' : ''));
|
||
button.dataset.episodeIndex = episode.index;
|
||
button.addEventListener('click', () => {playEpisode(episode.index); closeEpisodePanel();});
|
||
episodeList.appendChild(button);
|
||
return button;
|
||
});
|
||
updateEpisodeControls();
|
||
}
|
||
|
||
function heartbeat(fresh = false) {return keepalive.ping(fresh);}
|
||
|
||
function recoveryPosition() {
|
||
const position = !loading && !video.error && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition;
|
||
const duration = playbackDuration || video.duration;
|
||
return Math.max(0, Number.isFinite(duration) && duration > 0 ? Math.min(position, Math.max(0, duration - .25)) : position);
|
||
}
|
||
|
||
function recoverPlayback(index = currentIndex, offset = recoveryPosition(), shouldPlay = desiredPlayback) {
|
||
const episode = episodes[index - 1];
|
||
if (!panel.open || !episode || recoveryBlocked) return Promise.resolve(false);
|
||
const target = {index, offset, shouldPlay, chapter: episode.chapterId, episode: episode.episode, task: episode.taskId};
|
||
if (recoveryPromise) {if (arguments.length) recoveryTarget = target; return recoveryPromise;}
|
||
recoveryTarget = target;
|
||
sessionAvailable = false;
|
||
if (document.hidden || navigator.onLine === false) return Promise.resolve(false);
|
||
recoveryAttempts = recoveryAttempts.filter(time => Date.now() - time < 60000);
|
||
if (recoveryAttempts.length >= 2) {
|
||
showError(new Error('播放连接暂未恢复,请检查网络后重试'));
|
||
return Promise.resolve(false);
|
||
}
|
||
recoveryAttempts.push(Date.now());
|
||
const snapshot = historySnapshot(true, true);
|
||
if (snapshot) window.JukuHistory?.remember(snapshot.entry);
|
||
const completed = new Set(Array.from(completedEpisodes, number => episodes[number - 1]?.chapterId));
|
||
const previousSession = sessionID;
|
||
const version = ++openingVersion;
|
||
keepalive.stop();
|
||
openingController?.abort();
|
||
const controller = new AbortController();
|
||
openingController = controller;
|
||
historyPlayed = false;
|
||
stopStream();
|
||
sessionID = '';
|
||
releaseSession(previousSession);
|
||
errorText.textContent = '';
|
||
statusText.textContent = '正在恢复播放连接…';
|
||
updateEpisodeControls();
|
||
const promise = (async () => {
|
||
try {
|
||
const selection = collectionMode ? {taskId: target.task || collectionTaskID} : {dramaId: dramaID};
|
||
const result = await requestJSON('/api/ui/playback/open', {...selection, resume: false}, controller.signal);
|
||
if (version !== openingVersion || !panel.open) {releaseSession(result.session); return false;}
|
||
sessionID = result.session;
|
||
sessionAvailable = true;
|
||
mimeType = result.mimeType || mimeType;
|
||
dramaID = result.dramaId || dramaID;
|
||
dramaName = result.title || dramaName;
|
||
historySource = result.source || historySource;
|
||
releaseStatus = result.releaseStatus || releaseStatus;
|
||
episodes = result.episodes || [];
|
||
if (!episodes.length) throw new Error('站点没有返回可播放的分集');
|
||
const latest = recoveryTarget || target;
|
||
let selected = latest.chapter && episodes.find(item => item.chapterId === latest.chapter);
|
||
if (!selected) selected = episodes.find(item => item.episode === latest.episode);
|
||
if (!selected) throw new Error('原分集已不在当前选集中,请重新选集');
|
||
completedEpisodes.clear();
|
||
for (const item of episodes) if (item.chapterId && completed.has(item.chapterId)) completedEpisodes.add(item.index);
|
||
preparedIndex = 0;
|
||
historySequence = 0;
|
||
historySentAt = 0;
|
||
historyMessage = '已自动恢复到刚才的观看位置';
|
||
historyStatus.textContent = historyMessage;
|
||
node('playerTitle').textContent = dramaName;
|
||
renderEpisodes(Math.floor((selected.index - 1) / 30));
|
||
keepalive.start(sessionID);
|
||
recoveryTarget = null;
|
||
playEpisode(selected.index, Math.max(0, latest.offset), latest.shouldPlay, true);
|
||
return true;
|
||
} catch (error) {
|
||
if (version === openingVersion && error.name !== 'AbortError') {
|
||
if (error.status === 401 || error.status === 403) recoveryBlocked = true;
|
||
sessionAvailable = false;
|
||
showError(error);
|
||
}
|
||
return false;
|
||
} finally {
|
||
if (recoveryPromise === promise) recoveryPromise = null;
|
||
}
|
||
})();
|
||
recoveryPromise = promise;
|
||
return promise;
|
||
}
|
||
|
||
async function wakePlayback() {
|
||
if (!panel.open || document.hidden || navigator.onLine === false || recoveryBlocked || recoveryPromise) return;
|
||
if (!sessionAvailable) {
|
||
const target = recoveryTarget;
|
||
if (target) recoverPlayback(target.index, !loading && target.index === currentIndex ? recoveryPosition() : target.offset, !loading && target.index === currentIndex ? desiredPlayback : target.shouldPlay);
|
||
else recoverPlayback();
|
||
return;
|
||
}
|
||
const version = streamVersion;
|
||
const state = await heartbeat(true);
|
||
if (state && version === streamVersion && panel.open && video.error) recoverPlayback();
|
||
}
|
||
|
||
async function open(id, title, initialIndex = 0, offset = 0, taskID = '', resume = true, fromHistory = false) {
|
||
dispose();
|
||
recoveryAttempts = [];
|
||
recoveryBlocked = false;
|
||
planKey = '';
|
||
dramaID = id;
|
||
dramaName = title;
|
||
collectionTaskID = taskID;
|
||
collectionMode = Boolean(taskID);
|
||
episodes = [];
|
||
episodeButtons = [];
|
||
episodePage = 0;
|
||
completedEpisodes.clear();
|
||
currentIndex = 0;
|
||
lastPosition = offset;
|
||
historySource = '';
|
||
releaseStatus = '';
|
||
historySequence = 0;
|
||
historyMessage = '';
|
||
historySentAt = 0;
|
||
historyStatus.textContent = '';
|
||
node('playerTitle').textContent = title;
|
||
statusText.textContent = collectionMode ? '正在读取合集分集…' : '正在获取分集…';
|
||
updatePlaybackHint('');
|
||
errorText.textContent = '';
|
||
clear(episodeList);
|
||
updateEpisodeControls();
|
||
if (!panel.open) {panel.showModal(); video.focus({preventScroll: true});}
|
||
if (!supportsMediaSource('video/mp4; codecs="avc1.42C01F, mp4a.40.2"') && !supportsNativePlayback() && !capabilities.describe().mp4) {
|
||
showError(new Error('当前浏览器不支持 H.264 在线播放,请更新浏览器后重试'));
|
||
return;
|
||
}
|
||
const version = openingVersion;
|
||
openingController = new AbortController();
|
||
try {
|
||
const shouldResume = resume && initialIndex === 0;
|
||
const result = await requestJSON('/api/ui/playback/open', {...(taskID ? {taskId: taskID} : {dramaId: id}), resume: shouldResume, fromHistory}, openingController.signal);
|
||
if (version !== openingVersion || !panel.open) {
|
||
releaseSession(result.session);
|
||
return;
|
||
}
|
||
sessionID = result.session;
|
||
sessionAvailable = true;
|
||
mimeType = result.mimeType;
|
||
dramaID = result.dramaId || id;
|
||
dramaName = result.title || title;
|
||
historySource = result.source || '';
|
||
releaseStatus = result.releaseStatus || '';
|
||
collectionMode = result.mode === 'collection';
|
||
episodes = result.episodes || [];
|
||
const remembered = window.JukuHistory.get(dramaID);
|
||
if (remembered?.completed) {
|
||
const finished = episodes.find(episode => remembered.chapterId ? episode.chapterId === remembered.chapterId : episode.episode === remembered.episode);
|
||
if (finished) completedEpisodes.add(finished.index);
|
||
}
|
||
transport = capabilities.choose(mimeType);
|
||
allowRemux = transport === 'mse' && capabilities.remux();
|
||
if (!episodes.length) throw new Error('站点没有返回可播放的分集');
|
||
if (!transport && !capabilities.describe().mp4) throw new Error('当前浏览器没有可用的在线播放通道,请使用支持 HLS 或 H.264 的浏览器');
|
||
node('playerTitle').textContent = result.title || title;
|
||
renderEpisodes();
|
||
keepalive.start(sessionID);
|
||
historyMessage = shouldResume ? result.resumeMessage || '' : '';
|
||
historyStatus.textContent = historyMessage;
|
||
playEpisode(Math.min(Math.max(initialIndex || result.initialIndex || 1, 1), episodes.length), shouldResume ? Number(result.initialPosition) || 0 : offset, !shouldResume || !result.resumePaused, true);
|
||
} catch (error) {
|
||
if (version === openingVersion && error.name !== 'AbortError') showError(error);
|
||
}
|
||
}
|
||
|
||
function openCollection(taskID, title, resume = false) {
|
||
return open('', title, 0, 0, taskID, resume);
|
||
}
|
||
|
||
function openHistory(id, title) {
|
||
return open(id, title, 0, 0, '', true, true);
|
||
}
|
||
|
||
function reopen(index, offset) {
|
||
if (collectionMode) return open('', dramaName, 0, offset, episodes[index - 1]?.taskId || collectionTaskID, false);
|
||
return open(dramaID, dramaName, index, offset, '', false);
|
||
}
|
||
|
||
function updatePlaybackHint(source) {
|
||
if (source === 'merged' || episodes[currentIndex - 1]?.merged) {
|
||
node('playbackHint').textContent = '正在播放本地合并文件,支持拖动进度和继续观看。';
|
||
} else if (!collectionMode) {
|
||
const delivery = mediaPlan?.delivery === 'redirect' ? '当前为客户端直连,视频直接从源站获取。' : mediaPlan?.delivery === 'proxy' ? '当前由服务器中转视频。' : '';
|
||
node('playbackHint').textContent = delivery + '直接观看,不加入下载任务。开启预缓存后,临近播完时提前准备下一集;关闭窗口即停止取流并释放缓存。';
|
||
} else {
|
||
const prefix = source === 'local' ? '本集播放本地已完成文件。' : source === 'online' ? '本集在线缓冲,同时使用原下载任务保存视频。' : '已完成分集优先播放本地,播到未完成分集时自动下载该集。';
|
||
node('playbackHint').textContent = prefix + '只补下载播到的分集;关闭播放器不取消下载,可在下载合集中暂停或取消。';
|
||
}
|
||
}
|
||
|
||
function abortError() {
|
||
return new DOMException('播放已停止', 'AbortError');
|
||
}
|
||
|
||
function waitForEvent(target, eventName, signal, action) {
|
||
return new Promise((resolve, reject) => {
|
||
function cleanup() {
|
||
target.removeEventListener(eventName, done);
|
||
target.removeEventListener('error', failed);
|
||
signal.removeEventListener('abort', aborted);
|
||
}
|
||
function done() {cleanup(); resolve();}
|
||
function failed() {cleanup(); const error = new Error('浏览器无法解码此视频流'); error.playbackDecode = true; reject(error);}
|
||
function aborted() {cleanup(); reject(abortError());}
|
||
if (signal.aborted) {aborted(); return;}
|
||
target.addEventListener(eventName, done, {once: true});
|
||
target.addEventListener('error', failed, {once: true});
|
||
signal.addEventListener('abort', aborted, {once: true});
|
||
try {if (action) action();} catch (error) {cleanup(); reject(error);}
|
||
});
|
||
}
|
||
|
||
function bufferedAhead() {
|
||
for (let index = 0; index < video.buffered.length; index++) {
|
||
if (video.currentTime >= video.buffered.start(index) && video.currentTime <= video.buffered.end(index)) return video.buffered.end(index) - video.currentTime;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function schedulePrefetchStatus() {
|
||
clearTimeout(prefetchStatusTimer);
|
||
if (!prefetchPreparing || !prefetchToggle.checked || !panel.open || !sessionID) return;
|
||
const session = sessionID, run = playbackRun;
|
||
prefetchStatusTimer = setTimeout(async () => {
|
||
if (session !== sessionID || run !== playbackRun) return;
|
||
await heartbeat();
|
||
if (session === sessionID && run === playbackRun) schedulePrefetchStatus();
|
||
}, document.hidden ? 10000 : 2000);
|
||
}
|
||
|
||
function renderPrefetchStatus(view) {
|
||
if (!prefetchToggle.checked || !view || view.episode !== currentIndex + 1) return;
|
||
prefetchStatus.hidden = false;
|
||
prefetchStatus.textContent = view.state === 'resolved' ? '下一集地址已准备' : view.state === 'ready' ? '下一集已缓存' : view.state === 'partial' ? '下一集开头已缓存' : view.state === 'failed' ? '下一集将正常缓冲' : '正在缓存下一集…';
|
||
prefetchPreparing = !['ready', 'partial', 'resolved', 'failed'].includes(view.state);
|
||
schedulePrefetchStatus();
|
||
}
|
||
|
||
function maybePrefetchNext() {
|
||
if ((mediaPlan || transport === 'hls') && playbackDuration > 0 && video.currentTime > 0 && bufferedAhead() >= playbackDuration - video.currentTime - 0.5) streamComplete = true;
|
||
if (!prefetchToggle.checked || !streamComplete || loading || video.paused || video.ended || video.seeking || !panel.open || !sessionAvailable || !playbackRun || !currentIndex || currentIndex >= episodes.length || prefetchAttempted === currentIndex) return;
|
||
const remaining = video.duration - video.currentTime;
|
||
if (!Number.isFinite(remaining) || remaining <= 0 || remaining / Math.max(video.playbackRate, 0.25) > 30 || bufferedAhead() < remaining - 0.5) return;
|
||
const session = sessionID;
|
||
const run = playbackRun;
|
||
const version = ++prefetchVersion;
|
||
prefetchAttempted = currentIndex;
|
||
renderPrefetchStatus({episode: currentIndex + 1, state: 'preparing'});
|
||
requestJSON('/api/ui/playback/prefetch', {session, episode: currentIndex + 1, run, version, remux: allowRemux && transport === 'mse'}, openingController?.signal).then(view => {
|
||
if (session === sessionID && run === playbackRun && version === prefetchVersion) renderPrefetchStatus(view);
|
||
}).catch(error => {
|
||
if (session === sessionID && run === playbackRun && version === prefetchVersion && error.name !== 'AbortError') renderPrefetchStatus({episode: currentIndex + 1, state: 'failed'});
|
||
});
|
||
}
|
||
|
||
prefetchToggle.addEventListener('change', () => {
|
||
try {localStorage.setItem('juku.playback.prefetchNext', String(prefetchToggle.checked));} catch (_) {}
|
||
prefetchAttempted = 0;
|
||
const version = ++prefetchVersion;
|
||
prefetchStatus.hidden = true;
|
||
prefetchPreparing = false;
|
||
clearTimeout(prefetchStatusTimer);
|
||
if (prefetchToggle.checked) {
|
||
maybePrefetchNext();
|
||
} else if (sessionID && playbackRun) {
|
||
requestJSON('/api/ui/playback/prefetch', {session: sessionID, run: playbackRun, version, cancel: true}, openingController?.signal).catch(() => {});
|
||
}
|
||
});
|
||
|
||
async function trimBuffer(buffer, signal) {
|
||
const cutoff = video.currentTime - 20;
|
||
if (cutoff > 0 && buffer.buffered.length && buffer.buffered.start(0) < cutoff) {
|
||
await waitForEvent(buffer, 'updateend', signal, () => buffer.remove(0, cutoff));
|
||
}
|
||
}
|
||
|
||
async function playNative(index, offset, shouldPlay, version, currentSession, signal) {
|
||
const result = await requestJSON('/api/ui/playback/hls/open', {session: currentSession, episode: index, start: offset, quality, version}, signal);
|
||
if (signal.aborted || version !== streamVersion) throw abortError();
|
||
playbackRun = Number(result.run);
|
||
playbackDuration = Number(result.duration) || 0;
|
||
updatePlaybackHint(result.source);
|
||
updateQualities(result.qualities, Number(result.quality));
|
||
statusText.textContent = result.prefetched ? '正在读取预缓存…' : '正在缓冲…';
|
||
await waitForEvent(video, 'loadedmetadata', signal, () => {video.src = result.url; video.load();});
|
||
if (signal.aborted || version !== streamVersion) throw abortError();
|
||
if (offset > 0) video.currentTime = Math.min(offset, Math.max(0, playbackDuration - 0.05));
|
||
video.playbackRate = Number(node('playbackRate').value) || 1;
|
||
loading = false;
|
||
qualitySelect.disabled = false;
|
||
mobile.update();
|
||
statusText.textContent = shouldPlay ? '正在播放' : '已暂停';
|
||
if (shouldPlay) video.play().catch(error => {
|
||
if (version !== streamVersion || signal.aborted) return;
|
||
if (error.name === 'NotAllowedError') statusText.textContent = '已就绪,点击视频中的播放按钮';
|
||
else if (error.name !== 'AbortError') showError(error);
|
||
});
|
||
}
|
||
|
||
async function playPlanned(index, offset, shouldPlay, version, currentSession, signal) {
|
||
const result = await requestJSON('/api/ui/playback/plan', {session: currentSession, episode: index, start: offset, quality, version, mode: planMode, client: capabilities.describe()}, signal);
|
||
if (signal.aborted || version !== streamVersion) throw abortError();
|
||
if (result.player === 'legacy') {
|
||
planMode = 'legacy';
|
||
if (result.reason !== 'optimization_disabled') allowRemux = false;
|
||
playEpisode(index, offset, shouldPlay, true);
|
||
return;
|
||
}
|
||
mediaPlan = result;
|
||
transport = result.player === 'mp4' ? 'file' : 'original-hls';
|
||
playbackRun = Number(result.run);
|
||
playbackDuration = Number(result.duration) || 0;
|
||
updatePlaybackHint(result.source);
|
||
updateQualities(result.qualities, Number(result.quality));
|
||
statusText.textContent = '正在缓冲…';
|
||
await mediaLoader.load(result, supportsNativePlayback(), signal, offset);
|
||
if (signal.aborted || version !== streamVersion) throw abortError();
|
||
if (Number.isFinite(video.duration) && video.duration > playbackDuration) playbackDuration = video.duration;
|
||
if (offset > 0) video.currentTime = playbackDuration > 0 ? Math.min(offset, Math.max(0, playbackDuration - 0.05)) : offset;
|
||
video.playbackRate = Number(node('playbackRate').value) || 1;
|
||
loading = false;
|
||
qualitySelect.disabled = false;
|
||
mobile.update();
|
||
statusText.textContent = shouldPlay ? '正在播放' : '已暂停';
|
||
if (shouldPlay) video.play().catch(error => {
|
||
if (version !== streamVersion || signal.aborted) return;
|
||
if (error.name === 'NotAllowedError') statusText.textContent = '已就绪,点击视频中的播放按钮';
|
||
else if (error.name !== 'AbortError') showError(error);
|
||
});
|
||
}
|
||
|
||
async function playEpisode(index, offset = 0, shouldPlay = true, keepResumeMessage = false) {
|
||
if (!episodes[index - 1]) return;
|
||
const nextPlanKey = dramaID + ':' + index + ':' + quality;
|
||
if (planKey !== nextPlanKey) {planKey = nextPlanKey; planMode = 'auto'; planFallbacks = 0; networkRefreshes = 0;}
|
||
if (!sessionAvailable) {recoverPlayback(index, offset, shouldPlay); return;}
|
||
stopStream();
|
||
if (!keepResumeMessage) {
|
||
historyMessage = '';
|
||
historyStatus.textContent = '';
|
||
}
|
||
currentIndex = index;
|
||
streamRemux = false;
|
||
desiredPlayback = shouldPlay;
|
||
if (Math.floor((index - 1) / 30) !== episodePage) renderEpisodes(Math.floor((index - 1) / 30));
|
||
window.JukuPlaybackDanmaku?.setEpisode(sessionID, index, episodes[index - 1].danmaku);
|
||
lastPosition = offset;
|
||
errorText.textContent = '';
|
||
updateEpisodeControls();
|
||
statusText.textContent = offset > 0 ? '正在跳转并缓冲…' : '正在解析播放地址…';
|
||
const version = streamVersion;
|
||
const currentSession = sessionID;
|
||
const controller = new AbortController();
|
||
streamController = controller;
|
||
const signal = controller.signal;
|
||
let reader;
|
||
try {
|
||
if (collectionMode && preparedIndex !== index) {
|
||
statusText.textContent = episodes[index - 1].merged ? '正在读取本地合并文件…' : '正在检查本地分集并准备下载…';
|
||
const preparation = await requestJSON('/api/ui/playback/prepare', {session: currentSession, episode: index}, signal);
|
||
if (signal.aborted) throw abortError();
|
||
preparedIndex = index;
|
||
updatePlaybackHint(preparation.source);
|
||
window.dispatchEvent(new Event('downloadsChanged'));
|
||
}
|
||
if (planMode !== 'legacy') {
|
||
await playPlanned(index, offset, shouldPlay, version, currentSession, signal);
|
||
return;
|
||
}
|
||
if (transport !== 'mse' && transport !== 'hls') transport = capabilities.choose(mimeType);
|
||
if (!transport) throw new Error('浏览器没有可用的兼容播放通道');
|
||
if (transport === 'hls') {
|
||
await playNative(index, offset, shouldPlay, version, currentSession, signal);
|
||
return;
|
||
}
|
||
const source = new window.MediaSource();
|
||
objectURL = URL.createObjectURL(source);
|
||
await waitForEvent(source, 'sourceopen', signal, () => {video.src = objectURL;});
|
||
const response = await fetch('/api/ui/playback/stream?' + new URLSearchParams({session: currentSession, episode: String(index), start: String(offset), quality: String(quality), version: String(version), remux: allowRemux ? '1' : '0'}), {signal, cache: 'no-store', credentials: 'same-origin', headers: window.JukuViewer?.headers()});
|
||
if (!response.ok) {
|
||
const result = await response.json();
|
||
window.JukuViewer?.checkResponse(result);
|
||
const error = new Error(result.error || 'HTTP ' + response.status);
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
if (signal.aborted) throw abortError();
|
||
let options = [];
|
||
try {options = JSON.parse(response.headers.get('X-Playback-Qualities') || '[]');} catch (_) {}
|
||
updateQualities(options, Number(response.headers.get('X-Playback-Quality')));
|
||
streamRemux = response.headers.get('X-Playback-Mode') === 'remux';
|
||
const streamMIME = response.headers.get('X-Playback-MIME') || mimeType;
|
||
if (!supportsMediaSource(streamMIME)) {
|
||
if (fallbackPlayback(index, offset, shouldPlay, keepResumeMessage)) return;
|
||
throw new Error('浏览器不支持当前清晰度的编码,可切换较低清晰度后重试');
|
||
}
|
||
updatePlaybackHint(response.headers.get('X-Playback-Source'));
|
||
const duration = Number(response.headers.get('X-Playback-Duration'));
|
||
playbackDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
|
||
const run = Number(response.headers.get('X-Playback-Run'));
|
||
playbackRun = run;
|
||
const buffer = source.addSourceBuffer(streamMIME);
|
||
buffer.timestampOffset = offset;
|
||
if (duration > 0 && Number.isFinite(duration)) source.duration = duration;
|
||
statusText.textContent = response.headers.get('X-Playback-Prefetched') === '1' ? '正在读取预缓存…' : '正在缓冲…';
|
||
reader = response.body.getReader();
|
||
let initialized = false;
|
||
while (!signal.aborted) {
|
||
while (!signal.aborted && bufferedAhead() > (video.paused && initialized ? 5 : 30)) {
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
}
|
||
if (signal.aborted) throw abortError();
|
||
const chunk = await reader.read();
|
||
if (chunk.done) break;
|
||
await trimBuffer(buffer, signal);
|
||
await waitForEvent(buffer, 'updateend', signal, () => buffer.appendBuffer(chunk.value));
|
||
if (!initialized && buffer.buffered.length) {
|
||
initialized = true;
|
||
video.currentTime = Math.min(buffer.buffered.end(0) - 0.001, Math.max(offset, buffer.buffered.start(0) + 0.03));
|
||
video.playbackRate = Number(node('playbackRate').value) || 1;
|
||
loading = false;
|
||
qualitySelect.disabled = false;
|
||
mobile.update();
|
||
statusText.textContent = shouldPlay ? '正在播放' : '已暂停';
|
||
if (shouldPlay) video.play().catch(error => {
|
||
if (version !== streamVersion || signal.aborted) return;
|
||
if (error.name === 'NotAllowedError') statusText.textContent = '已就绪,点击视频中的播放按钮';
|
||
else if (error.name !== 'AbortError') showError(error);
|
||
});
|
||
}
|
||
}
|
||
if (signal.aborted) throw abortError();
|
||
const state = await requestJSON('/api/ui/playback/status?session=' + encodeURIComponent(currentSession), undefined, signal);
|
||
if (signal.aborted || version !== streamVersion) throw abortError();
|
||
if (state.run !== run || state.state !== 'ended') throw new Error(state.error || '视频连接中断,请重试');
|
||
if (!initialized) throw new Error('未收到可播放的视频画面');
|
||
if (source.readyState === 'open') source.endOfStream();
|
||
streamComplete = true;
|
||
maybePrefetchNext();
|
||
} catch (error) {
|
||
if (reader) await reader.cancel().catch(() => {});
|
||
if (version === streamVersion && !signal.aborted) {
|
||
if (error.status === 410) {recoverPlayback(index, Math.max(offset, lastPosition), desiredPlayback); return;}
|
||
if (mediaPlan && (error.playbackDecode || error.playbackNetwork) && await handleMediaFailure(error, version)) return;
|
||
const compatibleFailure = error.playbackDecode || error.name === 'NotSupportedError';
|
||
if (compatibleFailure && fallbackPlayback(index, Math.max(offset, lastPosition), desiredPlayback, keepResumeMessage)) return;
|
||
showError(error);
|
||
}
|
||
} finally {
|
||
if (reader) reader.releaseLock();
|
||
}
|
||
}
|
||
|
||
video.addEventListener('seeking', () => {
|
||
if (mediaPlan || transport === 'hls') return;
|
||
if (loading || !currentIndex || !Number.isFinite(video.currentTime)) return;
|
||
const target = video.currentTime;
|
||
clearTimeout(seekTimer);
|
||
for (let index = 0; index < video.buffered.length; index++) {
|
||
if (target >= video.buffered.start(index) && target < video.buffered.end(index)) return;
|
||
}
|
||
seekTimer = setTimeout(() => playEpisode(currentIndex, target, !video.paused), 180);
|
||
});
|
||
video.addEventListener('timeupdate', () => {if (!loading && Number.isFinite(video.currentTime)) {lastPosition = video.currentTime; if (!video.paused && !video.seeking) saveHistory();} maybePrefetchNext();});
|
||
video.addEventListener('playing', () => {desiredPlayback = true; if (!loading && !errorText.textContent) {historyPlayed = true; historyCompleted = false; statusText.textContent = '正在播放'; saveHistory(true);} maybePrefetchNext();});
|
||
video.addEventListener('waiting', () => {if (!loading && !errorText.textContent) statusText.textContent = '正在缓冲…';});
|
||
video.addEventListener('pause', () => {if (!loading && !video.error) desiredPlayback = false; if (!loading) saveHistory(true); if (!loading && !video.ended && !errorText.textContent) statusText.textContent = '已暂停';});
|
||
video.addEventListener('error', async () => {
|
||
if (!video.error || !video.hasAttribute('src') || !panel.open) return;
|
||
const version = streamVersion, code = video.error.code;
|
||
if (mediaPlan && loading) return;
|
||
if (mediaPlan) {
|
||
if (await handleMediaFailure({playbackNetwork: code === 2, playbackDecode: code !== 2}, version)) return;
|
||
} else if (transport === 'hls' || code === 2) {
|
||
await heartbeat(true);
|
||
if (version !== streamVersion || !panel.open || recoveryPromise || !sessionAvailable) return;
|
||
}
|
||
if ([3, 4].includes(code) && currentIndex && fallbackPlayback(currentIndex, lastPosition, desiredPlayback)) return;
|
||
if (streamController) streamController.abort();
|
||
showError(new Error('浏览器播放失败,请检查网络或重试(错误 ' + code + ')'));
|
||
});
|
||
video.addEventListener('ended', () => {
|
||
if (loading || !panel.open || errorText.textContent) return;
|
||
historyCompleted = true;
|
||
completedEpisodes.add(currentIndex);
|
||
updateEpisodeControls();
|
||
saveHistory(true);
|
||
if (node('autoNextEpisode').checked && currentIndex < episodes.length) playEpisode(currentIndex + 1);
|
||
else statusText.textContent = '本集播放完毕';
|
||
});
|
||
node('previousEpisodeBtn').addEventListener('click', () => playEpisode(currentIndex - 1));
|
||
node('nextEpisodeBtn').addEventListener('click', () => playEpisode(currentIndex + 1));
|
||
node('retryPlaybackBtn').addEventListener('click', () => {
|
||
planKey = '';
|
||
preparedIndex = 0;
|
||
recoveryAttempts = [];
|
||
recoveryBlocked = false;
|
||
if (episodes.length && sessionAvailable) playEpisode(currentIndex || 1, lastPosition);
|
||
else if (episodes.length) recoverPlayback(currentIndex || 1, lastPosition, true);
|
||
else reopen(currentIndex || 1, lastPosition);
|
||
});
|
||
node('playbackRate').addEventListener('change', () => {video.playbackRate = Number(node('playbackRate').value) || 1;});
|
||
qualitySelect.addEventListener('change', () => {
|
||
quality = Number(qualitySelect.value) || 0;
|
||
try {localStorage.setItem('juku.playback.quality', String(quality));} catch (_) {}
|
||
if (currentIndex) playEpisode(currentIndex, !loading && Number.isFinite(video.currentTime) ? video.currentTime : lastPosition, !video.paused);
|
||
});
|
||
node('closePlayerBtn').addEventListener('click', () => panel.close());
|
||
panel.addEventListener('close', () => {if (!panel.open) dispose();});
|
||
document.addEventListener('visibilitychange', () => {if (document.hidden) saveHistory(true); else wakePlayback();});
|
||
window.addEventListener('pagehide', () => {lastPosition = recoveryPosition(); dispose(true);});
|
||
for (const type of ['pageshow', 'online']) window.addEventListener(type, wakePlayback);
|
||
video.addEventListener('play', () => {if (!loading) wakePlayback();});
|
||
|
||
function showEpisodePanel() {
|
||
renderEpisodes(Math.floor(Math.max(0, currentIndex - 1) / 30));
|
||
if (mobile.active()) {
|
||
node('playerPreferences').hidden = true;
|
||
panel.classList.remove('preferences-open');
|
||
node('playerPreferencesBtn').setAttribute('aria-expanded', 'false');
|
||
panel.classList.add('episodes-open');
|
||
node('toggleEpisodesBtn').setAttribute('aria-expanded', 'true');
|
||
window.JukuDialogs.layer('episodes', true);
|
||
node('closeEpisodesBtn').focus();
|
||
} else node('episodeRange').focus();
|
||
}
|
||
|
||
function closeEpisodePanel() {
|
||
if (!panel.classList.contains('episodes-open')) return;
|
||
panel.classList.remove('episodes-open');
|
||
node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false');
|
||
window.JukuDialogs.layer('episodes', false);
|
||
(mobile.active() ? node('mobileEpisodesBtn') : node('toggleEpisodesBtn')).focus({preventScroll: true});
|
||
}
|
||
|
||
node('toggleEpisodesBtn').addEventListener('click', showEpisodePanel);
|
||
node('closeEpisodesBtn').addEventListener('click', closeEpisodePanel);
|
||
node('episodeRange').addEventListener('change', () => renderEpisodes(Number(node('episodeRange').value) || 0));
|
||
node('currentEpisodeBtn').addEventListener('click', () => {
|
||
renderEpisodes(Math.floor(Math.max(0, currentIndex - 1) / 30));
|
||
episodeButtons.find(button => Number(button.dataset.episodeIndex) === currentIndex)?.focus();
|
||
});
|
||
node('episodeJumpForm').addEventListener('submit', event => {
|
||
event.preventDefault();
|
||
const index = Number(node('jumpEpisode').value);
|
||
if (!Number.isInteger(index) || index < 1 || index > episodes.length) {
|
||
node('jumpEpisode').setCustomValidity('请输入 1 至 ' + episodes.length + ' 之间的集号');
|
||
node('jumpEpisode').reportValidity();
|
||
return;
|
||
}
|
||
node('jumpEpisode').setCustomValidity('');
|
||
playEpisode(index);
|
||
closeEpisodePanel();
|
||
});
|
||
node('jumpEpisode').addEventListener('input', () => node('jumpEpisode').setCustomValidity(''));
|
||
function setPlayerPreferences(visible, section = 'settings') {
|
||
controls.reset();
|
||
node('playerPreferences').hidden = !visible;
|
||
if (visible) panel.dataset.playerSheet = section;
|
||
panel.classList.toggle('preferences-open', visible);
|
||
node('playerPreferencesBtn').setAttribute('aria-expanded', String(visible));
|
||
if (mobile.active()) {
|
||
if (visible) {
|
||
panel.classList.remove('episodes-open');
|
||
node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false');
|
||
}
|
||
window.JukuDialogs.layer('player-settings', visible);
|
||
if (visible) node('closePlayerPreferencesBtn').focus({preventScroll: true});
|
||
} else if (visible) node('playerPreferences').scrollIntoView({block: 'nearest'});
|
||
mobile.update();
|
||
}
|
||
node('playerPreferencesBtn').addEventListener('click', () => setPlayerPreferences(node('playerPreferences').hidden));
|
||
node('closePlayerPreferencesBtn').addEventListener('click', () => {setPlayerPreferences(false); node('mobilePlayerSettingsBtn').focus({preventScroll: true});});
|
||
node('playerSheetBackdrop').addEventListener('click', () => {
|
||
if (panel.classList.contains('episodes-open')) closeEpisodePanel();
|
||
else setPlayerPreferences(false);
|
||
});
|
||
document.addEventListener('dialoglayerchange', event => {
|
||
const preferences = event.detail === 'player-settings' && panel.open;
|
||
node('playerPreferences').hidden = !preferences;
|
||
panel.classList.toggle('preferences-open', preferences);
|
||
node('playerPreferencesBtn').setAttribute('aria-expanded', String(preferences));
|
||
if (event.detail !== 'episodes') {
|
||
const wasOpen = panel.classList.contains('episodes-open');
|
||
panel.classList.remove('episodes-open');
|
||
node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false');
|
||
if (wasOpen && panel.open) node('toggleEpisodesBtn').focus({preventScroll: true});
|
||
} else if (panel.open) {
|
||
panel.classList.add('episodes-open');
|
||
node('toggleEpisodesBtn').setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
panel.addEventListener('close', () => {
|
||
panel.classList.remove('episodes-open', 'preferences-open');
|
||
node('playerPreferences').hidden = true;
|
||
node('playerPreferencesBtn').setAttribute('aria-expanded', 'false');
|
||
node('toggleEpisodesBtn').setAttribute('aria-expanded', 'false');
|
||
});
|
||
try {
|
||
node('autoNextEpisode').checked = localStorage.getItem('duanju.playback.autoNext') !== 'false';
|
||
const rate = localStorage.getItem('duanju.playback.rate');
|
||
if (Array.from(node('playbackRate').options).some(option => option.value === rate)) node('playbackRate').value = rate;
|
||
} catch (_) {}
|
||
node('autoNextEpisode').addEventListener('change', () => {try {localStorage.setItem('duanju.playback.autoNext', String(node('autoNextEpisode').checked));} catch (_) {}});
|
||
node('playbackRate').addEventListener('change', () => {try {localStorage.setItem('duanju.playback.rate', node('playbackRate').value);} catch (_) {}});
|
||
const pip = node('pictureInPictureBtn');
|
||
pip.hidden = !document.pictureInPictureEnabled || typeof video.requestPictureInPicture !== 'function';
|
||
pip.title = '画中画展示视频,网页弹幕留在当前页面';
|
||
pip.addEventListener('click', async () => {
|
||
try {
|
||
if (document.pictureInPictureElement === video) await document.exitPictureInPicture();
|
||
else await video.requestPictureInPicture();
|
||
} catch (_) {statusText.textContent = '画中画暂不可用,请先开始播放。';}
|
||
});
|
||
document.addEventListener('visibilitychange', schedulePrefetchStatus);
|
||
|
||
window.dramaPlayer = {open, openCollection, openHistory, updateDependency};
|
||
})();
|