(() => { 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}; })();