|
<!DOCTYPE html> |
|
<html lang="zh"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>LibreTV 播放器</title> |
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<link rel="stylesheet" href="css/styles.css"> |
|
<style> |
|
body, html { |
|
margin: 0; |
|
padding: 0; |
|
width: 100%; |
|
height: 100%; |
|
background-color: #0f1622; |
|
color: white; |
|
} |
|
.player-container { |
|
width: 100%; |
|
max-width: 1200px; |
|
margin: 0 auto; |
|
} |
|
#player { |
|
width: 100%; |
|
height: 60vh; |
|
} |
|
.loading-container { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
background-color: rgba(0, 0, 0, 0.7); |
|
color: white; |
|
z-index: 100; |
|
flex-direction: column; |
|
} |
|
.loading-spinner { |
|
width: 50px; |
|
height: 50px; |
|
border: 4px solid rgba(255, 255, 255, 0.3); |
|
border-radius: 50%; |
|
border-top-color: white; |
|
animation: spin 1s ease-in-out infinite; |
|
margin-bottom: 10px; |
|
} |
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
.error-container { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
display: none; |
|
align-items: center; |
|
justify-content: center; |
|
background-color: rgba(0, 0, 0, 0.7); |
|
color: white; |
|
z-index: 100; |
|
flex-direction: column; |
|
text-align: center; |
|
padding: 1rem; |
|
} |
|
.error-icon { |
|
font-size: 48px; |
|
margin-bottom: 10px; |
|
} |
|
.episode-active { |
|
background-color: #3b82f6 !important; |
|
border-color: #60a5fa !important; |
|
} |
|
.episode-grid { |
|
max-height: 30vh; |
|
overflow-y: auto; |
|
padding: 1rem 0; |
|
} |
|
.switch { |
|
position: relative; |
|
display: inline-block; |
|
width: 46px; |
|
height: 24px; |
|
} |
|
.switch input { |
|
opacity: 0; |
|
width: 0; |
|
height: 0; |
|
} |
|
.slider { |
|
position: absolute; |
|
cursor: pointer; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background-color: #333; |
|
transition: .4s; |
|
border-radius: 24px; |
|
} |
|
.slider:before { |
|
position: absolute; |
|
content: ""; |
|
height: 18px; |
|
width: 18px; |
|
left: 3px; |
|
bottom: 3px; |
|
background-color: white; |
|
transition: .4s; |
|
border-radius: 50%; |
|
} |
|
input:checked + .slider { |
|
background-color: #00ccff; |
|
} |
|
input:checked + .slider:before { |
|
transform: translateX(22px); |
|
} |
|
|
|
.shortcut-hint { |
|
position: fixed; |
|
top: 50%; |
|
left: 50%; |
|
transform: translate(-50%, -50%); |
|
background-color: rgba(0, 0, 0, 0.8); |
|
color: white; |
|
padding: 1rem 2rem; |
|
border-radius: 0.5rem; |
|
font-size: 1.5rem; |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
z-index: 1000; |
|
opacity: 0; |
|
transition: opacity 0.3s ease; |
|
} |
|
.shortcut-hint.show { |
|
opacity: 1; |
|
} |
|
|
|
|
|
.player-container:-webkit-full-screen, |
|
.player-container:fullscreen { |
|
position: fixed; |
|
top: 0; left: 0; |
|
width: 100vw; height: 100vh; |
|
z-index: 10000; |
|
background-color: #000; |
|
} |
|
.player-container:-webkit-full-screen #player, |
|
.player-container:fullscreen #player { |
|
width: 100%; height: 100%; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<header class="bg-[#111] p-4 flex justify-between items-center border-b border-[#333]"> |
|
<div class="flex items-center"> |
|
<a href="index.html" class="flex items-center"> |
|
<svg class="w-8 h-8 mr-2 text-[#00ccff]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path> |
|
</svg> |
|
<h1 class="text-xl font-bold gradient-text">LibreTV</h1> |
|
</a> |
|
</div> |
|
<h2 id="videoTitle" class="text-xl font-semibold truncate flex-1 text-center"></h2> |
|
<a href="index.html" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors"> |
|
返回首页 |
|
</a> |
|
</header> |
|
|
|
<main class="container mx-auto px-4 py-4"> |
|
|
|
<div id="playerContainer" class="player-container"> |
|
<div class="relative"> |
|
<div id="player"></div> |
|
<div class="loading-container" id="loading"> |
|
<div class="loading-spinner"></div> |
|
<div>正在加载视频...</div> |
|
</div> |
|
<div class="error-container" id="error"> |
|
<div class="error-icon">⚠️</div> |
|
<div id="error-message">视频加载失败</div> |
|
<div style="margin-top: 10px; font-size: 14px; color: #aaa;">请尝试其他视频源或稍后重试</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="player-container"> |
|
<div class="flex justify-between items-center my-4"> |
|
<button onclick="playPreviousEpisode()" id="prevButton" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors"> |
|
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> |
|
</svg> |
|
上一集 |
|
</button> |
|
<span class="text-gray-400" id="episodeInfo">加载中...</span> |
|
<button onclick="playNextEpisode()" id="nextButton" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors"> |
|
下一集 |
|
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> |
|
</svg> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="player-container"> |
|
<div class="flex justify-end items-center mb-4 gap-2"> |
|
<span class="text-gray-400 text-sm">自动连播</span> |
|
<label class="switch"> |
|
<input type="checkbox" id="autoplayToggle"> |
|
<span class="slider"></span> |
|
</label> |
|
<button onclick="toggleEpisodeOrder()" class="ml-4 px-4 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-semibold rounded-full shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center space-x-2"> |
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" id="orderIcon" viewBox="0 0 20 20" fill="currentColor"> |
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clip-rule="evenodd" /> |
|
</svg> |
|
<span id="orderText">倒序排列</span> |
|
</button> |
|
<button id="lockToggle" onclick="toggleControlsLock()" title="锁定控制" |
|
class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white rounded-full transition"> |
|
<svg id="lockIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" |
|
d="M15 11V7a3 3 0 00-6 0v4m-3 4h12v6H6v-6z" /> |
|
</svg> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="player-container"> |
|
<div class="episode-grid" id="episodesGrid"> |
|
<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2" id="episodesList"> |
|
|
|
<div class="col-span-full text-center text-gray-400 py-8">加载中...</div> |
|
</div> |
|
</div> |
|
</div> |
|
</main> |
|
|
|
|
|
<div class="shortcut-hint" id="shortcutHint"> |
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" id="shortcutIcon"> |
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> |
|
</svg> |
|
<span id="shortcutText"></span> |
|
</div> |
|
|
|
<script src="https://s4.zstatic.net/ajax/libs/hls.js/1.5.6/hls.min.js" integrity="sha256-X1GmLMzVcTBRiGjEau+gxGpjRK96atNczcLBg5w6hKA=" crossorigin="anonymous"></script> |
|
<script src="https://s4.zstatic.net/ajax/libs/dplayer/1.26.0/DPlayer.min.js" integrity="sha256-OJg03lDZP0NAcl3waC9OT5jEa8XZ8SM2n081Ik953o4=" crossorigin="anonymous"></script> |
|
<script src="js/config.js"></script> |
|
<script> |
|
|
|
let currentVideoTitle = ''; |
|
let currentEpisodeIndex = 0; |
|
let currentEpisodes = []; |
|
let episodesReversed = false; |
|
let dp = null; |
|
let currentHls = null; |
|
let autoplayEnabled = true; |
|
let isUserSeeking = false; |
|
let videoHasEnded = false; |
|
let userClickedPosition = null; |
|
let shortcutHintTimeout = null; |
|
let adFilteringEnabled = true; |
|
let progressSaveInterval = null; |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() { |
|
|
|
const urlParams = new URLSearchParams(window.location.search); |
|
const videoUrl = urlParams.get('url'); |
|
const title = urlParams.get('title'); |
|
let index = parseInt(urlParams.get('index') || '0'); |
|
const episodesList = urlParams.get('episodes'); |
|
|
|
|
|
currentVideoTitle = title || localStorage.getItem('currentVideoTitle') || '未知视频'; |
|
currentEpisodeIndex = index; |
|
|
|
|
|
autoplayEnabled = localStorage.getItem('autoplayEnabled') !== 'false'; |
|
document.getElementById('autoplayToggle').checked = autoplayEnabled; |
|
|
|
|
|
adFilteringEnabled = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; |
|
|
|
|
|
document.getElementById('autoplayToggle').addEventListener('change', function(e) { |
|
autoplayEnabled = e.target.checked; |
|
localStorage.setItem('autoplayEnabled', autoplayEnabled); |
|
}); |
|
|
|
|
|
try { |
|
if (episodesList) { |
|
|
|
currentEpisodes = JSON.parse(decodeURIComponent(episodesList)); |
|
console.log('从URL恢复集数信息:', currentEpisodes.length); |
|
} else { |
|
|
|
currentEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]'); |
|
console.log('从localStorage恢复集数信息:', currentEpisodes.length); |
|
} |
|
|
|
|
|
if (index < 0 || (currentEpisodes.length > 0 && index >= currentEpisodes.length)) { |
|
console.warn(`无效的剧集索引 ${index},调整为范围内的值`); |
|
|
|
|
|
if (index >= currentEpisodes.length && currentEpisodes.length > 0) { |
|
index = currentEpisodes.length - 1; |
|
} else { |
|
index = 0; |
|
} |
|
|
|
|
|
const newUrl = new URL(window.location.href); |
|
newUrl.searchParams.set('index', index); |
|
window.history.replaceState({}, '', newUrl); |
|
} |
|
|
|
|
|
currentEpisodeIndex = index; |
|
|
|
episodesReversed = localStorage.getItem('episodesReversed') === 'true'; |
|
} catch (e) { |
|
console.error('获取集数信息失败:', e); |
|
currentEpisodes = []; |
|
currentEpisodeIndex = 0; |
|
episodesReversed = false; |
|
} |
|
|
|
|
|
document.title = currentVideoTitle + ' - LibreTV播放器'; |
|
document.getElementById('videoTitle').textContent = currentVideoTitle; |
|
|
|
|
|
if (videoUrl) { |
|
initPlayer(videoUrl); |
|
|
|
|
|
const position = urlParams.get('position'); |
|
if (position) { |
|
setTimeout(() => { |
|
if (dp && dp.video) { |
|
const positionNum = parseInt(position); |
|
if (!isNaN(positionNum) && positionNum > 0) { |
|
dp.seek(positionNum); |
|
showPositionRestoreHint(positionNum); |
|
} |
|
} |
|
}, 1500); |
|
} |
|
} else { |
|
showError('无效的视频链接'); |
|
} |
|
|
|
|
|
updateEpisodeInfo(); |
|
|
|
|
|
renderEpisodes(); |
|
|
|
|
|
updateButtonStates(); |
|
|
|
|
|
updateOrderButton(); |
|
|
|
|
|
setTimeout(() => { |
|
setupProgressBarPreciseClicks(); |
|
}, 1000); |
|
|
|
|
|
document.addEventListener('keydown', handleKeyboardShortcuts); |
|
|
|
|
|
window.addEventListener('beforeunload', saveCurrentProgress); |
|
|
|
|
|
document.addEventListener('visibilitychange', function() { |
|
if (document.visibilityState === 'hidden') { |
|
saveCurrentProgress(); |
|
} |
|
}); |
|
|
|
|
|
|
|
const waitForVideo = setInterval(() => { |
|
if (dp && dp.video) { |
|
dp.video.addEventListener('pause', saveCurrentProgress); |
|
|
|
|
|
let lastSave = 0; |
|
dp.video.addEventListener('timeupdate', function() { |
|
const now = Date.now(); |
|
if (now - lastSave > 5000) { |
|
saveCurrentProgress(); |
|
lastSave = now; |
|
} |
|
}); |
|
|
|
clearInterval(waitForVideo); |
|
} |
|
}, 200); |
|
}); |
|
|
|
|
|
function handleKeyboardShortcuts(e) { |
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
|
|
|
|
|
if (e.altKey && e.key === 'ArrowLeft') { |
|
if (currentEpisodeIndex > 0) { |
|
playPreviousEpisode(); |
|
showShortcutHint('上一集', 'left'); |
|
e.preventDefault(); |
|
} |
|
} |
|
|
|
|
|
if (e.altKey && e.key === 'ArrowRight') { |
|
if (currentEpisodeIndex < currentEpisodes.length - 1) { |
|
playNextEpisode(); |
|
showShortcutHint('下一集', 'right'); |
|
e.preventDefault(); |
|
} |
|
} |
|
} |
|
|
|
|
|
function showShortcutHint(text, direction) { |
|
const hintElement = document.getElementById('shortcutHint'); |
|
const textElement = document.getElementById('shortcutText'); |
|
const iconElement = document.getElementById('shortcutIcon'); |
|
|
|
|
|
if (shortcutHintTimeout) { |
|
clearTimeout(shortcutHintTimeout); |
|
} |
|
|
|
|
|
textElement.textContent = text; |
|
|
|
if (direction === 'left') { |
|
iconElement.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>'; |
|
} else { |
|
iconElement.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>'; |
|
} |
|
|
|
|
|
hintElement.classList.add('show'); |
|
|
|
|
|
shortcutHintTimeout = setTimeout(() => { |
|
hintElement.classList.remove('show'); |
|
}, 2000); |
|
} |
|
|
|
|
|
function initPlayer(videoUrl) { |
|
if (!videoUrl) return; |
|
|
|
|
|
const hlsConfig = { |
|
debug: false, |
|
loader: adFilteringEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader, |
|
enableWorker: true, |
|
lowLatencyMode: false, |
|
backBufferLength: 90, |
|
maxBufferLength: 30, |
|
maxMaxBufferLength: 60, |
|
maxBufferSize: 30 * 1000 * 1000, |
|
maxBufferHole: 0.5, |
|
fragLoadingMaxRetry: 6, |
|
fragLoadingMaxRetryTimeout: 64000, |
|
fragLoadingRetryDelay: 1000, |
|
manifestLoadingMaxRetry: 3, |
|
manifestLoadingRetryDelay: 1000, |
|
levelLoadingMaxRetry: 4, |
|
levelLoadingRetryDelay: 1000, |
|
startLevel: -1, |
|
abrEwmaDefaultEstimate: 500000, |
|
abrBandWidthFactor: 0.95, |
|
abrBandWidthUpFactor: 0.7, |
|
abrMaxWithRealBitrate: true, |
|
stretchShortVideoTrack: true, |
|
appendErrorMaxRetry: 5, |
|
liveSyncDurationCount: 3, |
|
liveDurationInfinity: false |
|
}; |
|
|
|
|
|
dp = new DPlayer({ |
|
container: document.getElementById('player'), |
|
autoplay: true, |
|
theme: '#00ccff', |
|
preload: 'auto', |
|
loop: false, |
|
lang: 'zh-cn', |
|
hotkey: true, |
|
mutex: true, |
|
volume: 0.7, |
|
screenshot: true, |
|
preventClickToggle: false, |
|
airplay: true, |
|
chromecast: true, |
|
contextmenu: [ |
|
{ |
|
text: '关于 LibreTV', |
|
link: 'https://github.com/bestzwei/LibreTV' |
|
}, |
|
{ |
|
text: '问题反馈', |
|
click: (player) => { |
|
window.open('https://github.com/bestzwei/LibreTV/issues', '_blank'); |
|
} |
|
} |
|
], |
|
video: { |
|
url: videoUrl, |
|
type: 'hls', |
|
pic: 'https://img.picgo.net/2025/04/12/image362e7d38b4af4a74.png', |
|
customType: { |
|
hls: function(video, player) { |
|
|
|
if (currentHls && currentHls.destroy) { |
|
try { |
|
currentHls.destroy(); |
|
} catch (e) { |
|
console.warn('销毁旧HLS实例出错:', e); |
|
} |
|
} |
|
|
|
|
|
const hls = new Hls(hlsConfig); |
|
currentHls = hls; |
|
|
|
|
|
let errorDisplayed = false; |
|
|
|
let errorCount = 0; |
|
|
|
let playbackStarted = false; |
|
|
|
let bufferAppendErrorCount = 0; |
|
|
|
|
|
video.addEventListener('playing', function() { |
|
playbackStarted = true; |
|
document.getElementById('loading').style.display = 'none'; |
|
document.getElementById('error').style.display = 'none'; |
|
}); |
|
|
|
|
|
video.addEventListener('timeupdate', function() { |
|
if (video.currentTime > 1) { |
|
|
|
document.getElementById('error').style.display = 'none'; |
|
} |
|
}); |
|
|
|
hls.loadSource(video.src); |
|
hls.attachMedia(video); |
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, function() { |
|
video.play().catch(e => { |
|
console.warn('自动播放被阻止:', e); |
|
}); |
|
}); |
|
|
|
hls.on(Hls.Events.ERROR, function(event, data) { |
|
console.log('HLS事件:', event, '数据:', data); |
|
|
|
|
|
errorCount++; |
|
|
|
|
|
if (data.details === 'bufferAppendError') { |
|
bufferAppendErrorCount++; |
|
console.warn(`bufferAppendError 发生 ${bufferAppendErrorCount} 次`); |
|
|
|
|
|
if (playbackStarted) { |
|
console.log('视频已在播放中,忽略bufferAppendError'); |
|
return; |
|
} |
|
|
|
|
|
if (bufferAppendErrorCount >= 3) { |
|
hls.recoverMediaError(); |
|
} |
|
} |
|
|
|
|
|
if (data.fatal && !playbackStarted) { |
|
console.error('致命HLS错误:', data); |
|
|
|
|
|
switch(data.type) { |
|
case Hls.ErrorTypes.NETWORK_ERROR: |
|
console.log("尝试恢复网络错误"); |
|
hls.startLoad(); |
|
break; |
|
case Hls.ErrorTypes.MEDIA_ERROR: |
|
console.log("尝试恢复媒体错误"); |
|
hls.recoverMediaError(); |
|
break; |
|
default: |
|
|
|
if (errorCount > 3 && !errorDisplayed) { |
|
errorDisplayed = true; |
|
showError('视频加载失败,可能是格式不兼容或源不可用'); |
|
} |
|
break; |
|
} |
|
} |
|
}); |
|
|
|
|
|
hls.on(Hls.Events.FRAG_LOADED, function() { |
|
document.getElementById('loading').style.display = 'none'; |
|
}); |
|
|
|
|
|
hls.on(Hls.Events.LEVEL_LOADED, function() { |
|
document.getElementById('loading').style.display = 'none'; |
|
}); |
|
|
|
|
|
let tmp_time_add = 0.1; |
|
const tmp_max_buffer_length = hls.config.maxBufferLength; |
|
hls.on(Hls.Events.FRAG_PARSED, (event, data) => { |
|
if (data.frag.endList) { |
|
const cur = hls.media.currentTime; |
|
const dur = hls.media.duration || 0; |
|
if (cur < dur) { |
|
data.frag.endList = undefined; |
|
|
|
hls.config.maxBufferLength = tmp_time_add < 1 |
|
? 2 |
|
: tmp_max_buffer_length; |
|
|
|
hls.loadSource(video.src); |
|
hls.attachMedia(video); |
|
hls.media.currentTime = cur + tmp_time_add; |
|
|
|
tmp_time_add = tmp_time_add < 1 ? 5 : 0.1; |
|
player.video.play().catch(() => {}); |
|
} else { |
|
player.video.pause(); |
|
} |
|
} |
|
}); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
dp.on('fullscreen', () => { |
|
if (window.screen.orientation && window.screen.orientation.lock) { |
|
window.screen.orientation.lock('landscape') |
|
.then(() => { |
|
console.log('屏幕已锁定为横向模式'); |
|
}) |
|
.catch((error) => { |
|
console.warn('无法锁定屏幕方向,请手动旋转设备:', error); |
|
}); |
|
} else { |
|
console.warn('当前浏览器不支持锁定屏幕方向,请手动旋转设备。'); |
|
} |
|
}); |
|
|
|
|
|
dp.on('fullscreen_cancel', () => { |
|
if (window.screen.orientation && window.screen.orientation.unlock) { |
|
window.screen.orientation.unlock(); |
|
} |
|
}); |
|
|
|
dp.on('loadedmetadata', function() { |
|
document.getElementById('loading').style.display = 'none'; |
|
videoHasEnded = false; |
|
|
|
|
|
setupProgressBarPreciseClicks(); |
|
|
|
|
|
setTimeout(saveToHistory, 3000); |
|
|
|
|
|
startProgressSaveInterval(); |
|
}); |
|
|
|
dp.on('error', function() { |
|
|
|
if (dp.video && dp.video.currentTime > 1) { |
|
console.log('发生错误,但视频已在播放中,忽略'); |
|
return; |
|
} |
|
showError('视频播放失败,请检查视频源或网络连接'); |
|
}); |
|
|
|
|
|
dp.on('seeking', function() { |
|
isUserSeeking = true; |
|
videoHasEnded = false; |
|
|
|
|
|
if (userClickedPosition !== null && dp.video) { |
|
|
|
const clickedTime = userClickedPosition; |
|
|
|
|
|
if (Math.abs(dp.video.duration - clickedTime) < 0.5) { |
|
|
|
dp.video.currentTime = Math.max(0, clickedTime - 0.5); |
|
} else { |
|
dp.video.currentTime = clickedTime; |
|
} |
|
|
|
|
|
setTimeout(() => { |
|
userClickedPosition = null; |
|
}, 200); |
|
} |
|
}); |
|
|
|
|
|
dp.on('seeked', function() { |
|
|
|
if (dp.video && dp.video.duration > 0) { |
|
const timeFromEnd = dp.video.duration - dp.video.currentTime; |
|
if (timeFromEnd < 0.3 && isUserSeeking) { |
|
|
|
dp.video.currentTime = Math.max(0, dp.video.currentTime - 1); |
|
} |
|
} |
|
|
|
|
|
setTimeout(() => { |
|
isUserSeeking = false; |
|
}, 200); |
|
}); |
|
|
|
|
|
dp.on('ended', function() { |
|
videoHasEnded = true; |
|
|
|
|
|
clearVideoProgress(); |
|
|
|
|
|
if (autoplayEnabled && currentEpisodeIndex < currentEpisodes.length - 1) { |
|
console.log('视频播放结束,自动播放下一集'); |
|
|
|
setTimeout(() => { |
|
|
|
if (videoHasEnded && !isUserSeeking) { |
|
playNextEpisode(); |
|
videoHasEnded = false; |
|
} |
|
}, 1000); |
|
} else { |
|
console.log('视频播放结束,无下一集或未启用自动连播'); |
|
} |
|
}); |
|
|
|
|
|
dp.on('timeupdate', function() { |
|
if (dp.video && dp.duration > 0) { |
|
|
|
if (isUserSeeking && dp.video.currentTime > dp.video.duration * 0.95) { |
|
videoHasEnded = false; |
|
} |
|
} |
|
}); |
|
|
|
|
|
setTimeout(function() { |
|
|
|
if (dp && dp.video && dp.video.currentTime > 0) { |
|
return; |
|
} |
|
|
|
if (document.getElementById('loading').style.display !== 'none') { |
|
document.getElementById('loading').innerHTML = ` |
|
<div class="loading-spinner"></div> |
|
<div>视频加载时间较长,请耐心等待...</div> |
|
<div style="font-size: 12px; color: #aaa; margin-top: 10px;">如长时间无响应,请尝试其他视频源</div> |
|
`; |
|
} |
|
}, 10000); |
|
|
|
|
|
(function(){ |
|
const fsContainer = document.getElementById('playerContainer'); |
|
dp.on('fullscreen', () => { |
|
if (fsContainer.requestFullscreen) { |
|
fsContainer.requestFullscreen().catch(err => console.warn('原生全屏失败:', err)); |
|
} |
|
}); |
|
dp.on('fullscreen_cancel', () => { |
|
if (document.fullscreenElement) { |
|
document.exitFullscreen(); |
|
} |
|
}); |
|
})(); |
|
} |
|
|
|
|
|
class CustomHlsJsLoader extends Hls.DefaultConfig.loader { |
|
constructor(config) { |
|
super(config); |
|
const load = this.load.bind(this); |
|
this.load = function(context, config, callbacks) { |
|
|
|
if (context.type === 'manifest' || context.type === 'level') { |
|
const onSuccess = callbacks.onSuccess; |
|
callbacks.onSuccess = function(response, stats, context) { |
|
|
|
if (response.data && typeof response.data === 'string') { |
|
|
|
response.data = filterAdsFromM3U8(response.data, true); |
|
} |
|
return onSuccess(response, stats, context); |
|
}; |
|
} |
|
|
|
load(context, config, callbacks); |
|
}; |
|
} |
|
} |
|
|
|
|
|
function filterAdsFromM3U8(m3u8Content, strictMode = false) { |
|
if (!m3u8Content) return ''; |
|
|
|
|
|
const lines = m3u8Content.split('\n'); |
|
const filteredLines = []; |
|
|
|
for (let i = 0; i < lines.length; i++) { |
|
const line = lines[i]; |
|
|
|
|
|
if (!line.includes('#EXT-X-DISCONTINUITY')) { |
|
filteredLines.push(line); |
|
} |
|
} |
|
|
|
return filteredLines.join('\n'); |
|
} |
|
|
|
|
|
function showError(message) { |
|
|
|
if (dp && dp.video && dp.video.currentTime > 1) { |
|
console.log('忽略错误:', message); |
|
return; |
|
} |
|
|
|
document.getElementById('loading').style.display = 'none'; |
|
document.getElementById('error').style.display = 'flex'; |
|
document.getElementById('error-message').textContent = message; |
|
} |
|
|
|
|
|
function updateEpisodeInfo() { |
|
if (currentEpisodes.length > 0) { |
|
document.getElementById('episodeInfo').textContent = `第 ${currentEpisodeIndex + 1}/${currentEpisodes.length} 集`; |
|
} else { |
|
document.getElementById('episodeInfo').textContent = '无集数信息'; |
|
} |
|
} |
|
|
|
|
|
function updateButtonStates() { |
|
const prevButton = document.getElementById('prevButton'); |
|
const nextButton = document.getElementById('nextButton'); |
|
|
|
|
|
if (currentEpisodeIndex > 0) { |
|
prevButton.classList.remove('bg-gray-700', 'cursor-not-allowed'); |
|
prevButton.classList.add('bg-[#222]', 'hover:bg-[#333]'); |
|
prevButton.removeAttribute('disabled'); |
|
} else { |
|
prevButton.classList.add('bg-gray-700', 'cursor-not-allowed'); |
|
prevButton.classList.remove('bg-[#222]', 'hover:bg-[#333]'); |
|
prevButton.setAttribute('disabled', ''); |
|
} |
|
|
|
|
|
if (currentEpisodeIndex < currentEpisodes.length - 1) { |
|
nextButton.classList.remove('bg-gray-700', 'cursor-not-allowed'); |
|
nextButton.classList.add('bg-[#222]', 'hover:bg-[#333]'); |
|
nextButton.removeAttribute('disabled'); |
|
} else { |
|
nextButton.classList.add('bg-gray-700', 'cursor-not-allowed'); |
|
nextButton.classList.remove('bg-[#222]', 'hover:bg-[#333]'); |
|
nextButton.setAttribute('disabled', ''); |
|
} |
|
} |
|
|
|
|
|
function renderEpisodes() { |
|
const episodesList = document.getElementById('episodesList'); |
|
if (!episodesList) return; |
|
|
|
if (!currentEpisodes || currentEpisodes.length === 0) { |
|
episodesList.innerHTML = '<div class="col-span-full text-center text-gray-400 py-8">没有可用的集数</div>'; |
|
return; |
|
} |
|
|
|
const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes; |
|
let html = ''; |
|
|
|
episodes.forEach((episode, index) => { |
|
|
|
const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index; |
|
const isActive = realIndex === currentEpisodeIndex; |
|
|
|
html += ` |
|
<button id="episode-${realIndex}" |
|
onclick="playEpisode(${realIndex})" |
|
class="px-4 py-2 ${isActive ? 'episode-active' : 'bg-[#222] hover:bg-[#333]'} border ${isActive ? 'border-blue-500' : 'border-[#333]'} rounded-lg transition-colors text-center episode-btn"> |
|
第${realIndex + 1}集 |
|
</button> |
|
`; |
|
}); |
|
|
|
episodesList.innerHTML = html; |
|
} |
|
|
|
|
|
function playEpisode(index) { |
|
|
|
if (index < 0 || index >= currentEpisodes.length) { |
|
console.error(`无效的剧集索引: ${index}, 当前剧集数量: ${currentEpisodes.length}`); |
|
showToast(`无效的剧集索引: ${index + 1},当前剧集总数: ${currentEpisodes.length}`); |
|
return; |
|
} |
|
|
|
|
|
if (dp && dp.video && !dp.video.paused && !videoHasEnded) { |
|
saveCurrentProgress(); |
|
} |
|
|
|
|
|
if (progressSaveInterval) { |
|
clearInterval(progressSaveInterval); |
|
progressSaveInterval = null; |
|
} |
|
|
|
|
|
document.getElementById('error').style.display = 'none'; |
|
|
|
document.getElementById('loading').style.display = 'flex'; |
|
document.getElementById('loading').innerHTML = ` |
|
<div class="loading-spinner"></div> |
|
<div>正在加载视频...</div> |
|
`; |
|
|
|
const url = currentEpisodes[index]; |
|
currentEpisodeIndex = index; |
|
videoHasEnded = false; |
|
|
|
|
|
const urlParams = new URLSearchParams(window.location.search); |
|
const sourceName = urlParams.get('source') || ''; |
|
|
|
|
|
const newUrl = new URL(window.location.href); |
|
newUrl.searchParams.set('index', index); |
|
newUrl.searchParams.set('url', url); |
|
if (sourceName) { |
|
newUrl.searchParams.set('source', sourceName); |
|
} |
|
window.history.pushState({}, '', newUrl); |
|
|
|
|
|
if (dp) { |
|
try { |
|
dp.switchVideo({ |
|
url: url, |
|
type: 'hls' |
|
}); |
|
|
|
|
|
const playPromise = dp.play(); |
|
if (playPromise !== undefined) { |
|
playPromise.catch(error => { |
|
console.warn('播放失败,尝试重新初始化:', error); |
|
|
|
initPlayer(url); |
|
}); |
|
} |
|
} catch (e) { |
|
console.error('切换视频出错,尝试重新初始化:', e); |
|
|
|
initPlayer(url); |
|
} |
|
} else { |
|
initPlayer(url); |
|
} |
|
|
|
|
|
updateEpisodeInfo(); |
|
updateButtonStates(); |
|
renderEpisodes(); |
|
|
|
|
|
userClickedPosition = null; |
|
|
|
|
|
setTimeout(() => saveToHistory(), 3000); |
|
} |
|
|
|
|
|
function playPreviousEpisode() { |
|
if (currentEpisodeIndex > 0) { |
|
playEpisode(currentEpisodeIndex - 1); |
|
} |
|
} |
|
|
|
|
|
function playNextEpisode() { |
|
if (currentEpisodeIndex < currentEpisodes.length - 1) { |
|
playEpisode(currentEpisodeIndex + 1); |
|
} |
|
} |
|
|
|
|
|
function toggleEpisodeOrder() { |
|
episodesReversed = !episodesReversed; |
|
|
|
|
|
localStorage.setItem('episodesReversed', episodesReversed); |
|
|
|
|
|
renderEpisodes(); |
|
|
|
|
|
updateOrderButton(); |
|
} |
|
|
|
|
|
function updateOrderButton() { |
|
const orderText = document.getElementById('orderText'); |
|
const orderIcon = document.getElementById('orderIcon'); |
|
|
|
if (orderText && orderIcon) { |
|
orderText.textContent = episodesReversed ? '正序排列' : '倒序排列'; |
|
orderIcon.style.transform = episodesReversed ? 'rotate(180deg)' : ''; |
|
} |
|
} |
|
|
|
|
|
function setupProgressBarPreciseClicks() { |
|
|
|
const progressBar = document.querySelector('.dplayer-bar-wrap'); |
|
if (!progressBar || !dp || !dp.video) return; |
|
|
|
|
|
progressBar.removeEventListener('mousedown', handleProgressBarClick); |
|
|
|
|
|
progressBar.addEventListener('mousedown', handleProgressBarClick); |
|
|
|
|
|
progressBar.removeEventListener('touchstart', handleProgressBarTouch); |
|
progressBar.addEventListener('touchstart', handleProgressBarTouch); |
|
|
|
console.log('进度条精确点击监听器已设置'); |
|
} |
|
|
|
|
|
function handleProgressBarClick(e) { |
|
if (!dp || !dp.video) return; |
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect(); |
|
const percentage = (e.clientX - rect.left) / rect.width; |
|
|
|
|
|
const duration = dp.video.duration; |
|
let clickTime = percentage * duration; |
|
|
|
|
|
if (duration - clickTime < 1) { |
|
|
|
clickTime = Math.min(clickTime, duration - 1.5); |
|
console.log(`进度条点击接近结尾,调整时间为 ${clickTime.toFixed(2)}/${duration.toFixed(2)}`); |
|
} |
|
|
|
|
|
userClickedPosition = clickTime; |
|
|
|
|
|
console.log(`进度条点击: ${percentage.toFixed(4)}, 时间: ${clickTime.toFixed(2)}/${duration.toFixed(2)}`); |
|
|
|
|
|
e.stopPropagation(); |
|
|
|
|
|
dp.seek(clickTime); |
|
} |
|
|
|
|
|
function handleProgressBarTouch(e) { |
|
if (!dp || !dp.video || !e.touches[0]) return; |
|
|
|
const touch = e.touches[0]; |
|
const rect = e.currentTarget.getBoundingClientRect(); |
|
const percentage = (touch.clientX - rect.left) / rect.width; |
|
|
|
const duration = dp.video.duration; |
|
let clickTime = percentage * duration; |
|
|
|
|
|
if (duration - clickTime < 1) { |
|
clickTime = Math.min(clickTime, duration - 1.5); |
|
} |
|
|
|
|
|
userClickedPosition = clickTime; |
|
|
|
console.log(`进度条触摸: ${percentage.toFixed(4)}, 时间: ${clickTime.toFixed(2)}/${duration.toFixed(2)}`); |
|
|
|
e.stopPropagation(); |
|
dp.seek(clickTime); |
|
} |
|
|
|
|
|
function saveToHistory() { |
|
|
|
if (!currentEpisodes || currentEpisodes.length === 0) { |
|
console.warn('没有可用的剧集列表,无法保存完整的历史记录'); |
|
} |
|
|
|
|
|
const urlParams = new URLSearchParams(window.location.search); |
|
const sourceName = urlParams.get('source') || ''; |
|
|
|
|
|
let currentPosition = 0; |
|
let videoDuration = 0; |
|
|
|
if (dp && dp.video) { |
|
currentPosition = dp.video.currentTime; |
|
videoDuration = dp.video.duration; |
|
} |
|
|
|
|
|
const videoInfo = { |
|
title: currentVideoTitle, |
|
|
|
url: `player.html?title=${encodeURIComponent(currentVideoTitle)}&source=${encodeURIComponent(sourceName)}`, |
|
episodeIndex: currentEpisodeIndex, |
|
sourceName: sourceName, |
|
timestamp: Date.now(), |
|
|
|
playbackPosition: currentPosition > 10 ? currentPosition : 0, |
|
duration: videoDuration, |
|
|
|
episodes: currentEpisodes && currentEpisodes.length > 0 ? [...currentEpisodes] : [] |
|
}; |
|
|
|
|
|
if (typeof addToViewingHistory === 'function') { |
|
addToViewingHistory(videoInfo); |
|
console.log(`已保存 "${currentVideoTitle}" 的历史记录, 集数数据: ${currentEpisodes.length}集`); |
|
} else { |
|
|
|
try { |
|
const history = JSON.parse(localStorage.getItem('viewingHistory') || '[]'); |
|
|
|
|
|
const existingIndex = history.findIndex(item => item.title === videoInfo.title); |
|
if (existingIndex !== -1) { |
|
|
|
history[existingIndex].episodeIndex = currentEpisodeIndex; |
|
history[existingIndex].timestamp = Date.now(); |
|
|
|
history[existingIndex].playbackPosition = currentPosition > 10 ? currentPosition : history[existingIndex].playbackPosition; |
|
history[existingIndex].duration = videoDuration || history[existingIndex].duration; |
|
|
|
history[existingIndex].url = window.location.href; |
|
|
|
if (currentEpisodes && currentEpisodes.length > 0) { |
|
|
|
if (!history[existingIndex].episodes || |
|
!Array.isArray(history[existingIndex].episodes) || |
|
history[existingIndex].episodes.length !== currentEpisodes.length) { |
|
history[existingIndex].episodes = [...currentEpisodes]; |
|
console.log(`更新 "${currentVideoTitle}" 的剧集数据: ${currentEpisodes.length}集`); |
|
} |
|
} |
|
|
|
|
|
const updatedItem = history.splice(existingIndex, 1)[0]; |
|
history.unshift(updatedItem); |
|
} else { |
|
|
|
videoInfo.url = window.location.href; |
|
console.log(`创建新的历史记录: "${currentVideoTitle}", ${currentEpisodes.length}集`); |
|
history.unshift(videoInfo); |
|
} |
|
|
|
|
|
if (history.length > 50) history.splice(50); |
|
|
|
localStorage.setItem('viewingHistory', JSON.stringify(history)); |
|
} catch (e) { |
|
console.error('保存观看历史失败:', e); |
|
} |
|
} |
|
} |
|
|
|
|
|
function showPositionRestoreHint(position) { |
|
if (!position || position < 10) return; |
|
|
|
|
|
const hint = document.createElement('div'); |
|
hint.className = 'position-restore-hint'; |
|
hint.innerHTML = ` |
|
<div class="hint-content"> |
|
已从 ${formatTime(position)} 继续播放 |
|
</div> |
|
`; |
|
|
|
|
|
const playerContainer = document.querySelector('.player-container'); |
|
playerContainer.appendChild(hint); |
|
|
|
|
|
setTimeout(() => { |
|
hint.classList.add('show'); |
|
|
|
|
|
setTimeout(() => { |
|
hint.classList.remove('show'); |
|
setTimeout(() => hint.remove(), 300); |
|
}, 3000); |
|
}, 100); |
|
} |
|
|
|
|
|
function formatTime(seconds) { |
|
if (isNaN(seconds)) return '00:00'; |
|
|
|
const minutes = Math.floor(seconds / 60); |
|
const remainingSeconds = Math.floor(seconds % 60); |
|
|
|
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; |
|
} |
|
|
|
|
|
function startProgressSaveInterval() { |
|
|
|
if (progressSaveInterval) { |
|
clearInterval(progressSaveInterval); |
|
} |
|
|
|
|
|
progressSaveInterval = setInterval(saveCurrentProgress, 30000); |
|
} |
|
|
|
|
|
function saveCurrentProgress() { |
|
if (!dp || !dp.video) return; |
|
const currentTime = dp.video.currentTime; |
|
const duration = dp.video.duration; |
|
if (!duration || currentTime < 1) return; |
|
|
|
|
|
const progressKey = `videoProgress_${getVideoId()}`; |
|
const progressData = { |
|
position: currentTime, |
|
duration: duration, |
|
timestamp: Date.now() |
|
}; |
|
try { |
|
localStorage.setItem(progressKey, JSON.stringify(progressData)); |
|
|
|
try { |
|
const historyRaw = localStorage.getItem('viewingHistory'); |
|
if (historyRaw) { |
|
const history = JSON.parse(historyRaw); |
|
|
|
const idx = history.findIndex(item => |
|
item.title === currentVideoTitle && |
|
(item.episodeIndex === undefined || item.episodeIndex === currentEpisodeIndex) |
|
); |
|
if (idx !== -1) { |
|
|
|
if ( |
|
Math.abs((history[idx].playbackPosition || 0) - currentTime) > 2 || |
|
Math.abs((history[idx].duration || 0) - duration) > 2 |
|
) { |
|
history[idx].playbackPosition = currentTime; |
|
history[idx].duration = duration; |
|
history[idx].timestamp = Date.now(); |
|
localStorage.setItem('viewingHistory', JSON.stringify(history)); |
|
} |
|
} |
|
} |
|
} catch (e) { |
|
|
|
} |
|
} catch (e) { |
|
console.error('保存播放进度失败', e); |
|
} |
|
} |
|
|
|
|
|
function clearVideoProgress() { |
|
const progressKey = `videoProgress_${getVideoId()}`; |
|
try { |
|
localStorage.removeItem(progressKey); |
|
console.log('已清除播放进度记录'); |
|
} catch (e) { |
|
console.error('清除播放进度记录失败', e); |
|
} |
|
} |
|
|
|
|
|
function getVideoId() { |
|
|
|
return `${encodeURIComponent(currentVideoTitle)}_${currentEpisodeIndex}`; |
|
} |
|
|
|
|
|
function showToast(message, type = 'error') { |
|
|
|
const existingToast = document.getElementById('custom-toast'); |
|
if (existingToast) { |
|
document.body.removeChild(existingToast); |
|
} |
|
|
|
|
|
const toast = document.createElement('div'); |
|
toast.id = 'custom-toast'; |
|
|
|
|
|
toast.style.position = 'fixed'; |
|
toast.style.top = '20px'; |
|
toast.style.left = '50%'; |
|
toast.style.transform = 'translateX(-50%)'; |
|
toast.style.backgroundColor = type === 'error' ? '#f44336' : '#4caf50'; |
|
toast.style.color = 'white'; |
|
toast.style.padding = '12px 20px'; |
|
toast.style.borderRadius = '4px'; |
|
toast.style.zIndex = '10000'; |
|
toast.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)'; |
|
toast.style.opacity = '0'; |
|
toast.style.transition = 'opacity 0.3s ease-in-out'; |
|
|
|
|
|
toast.textContent = message; |
|
|
|
|
|
document.body.appendChild(toast); |
|
|
|
|
|
setTimeout(() => { |
|
toast.style.opacity = '1'; |
|
|
|
|
|
setTimeout(() => { |
|
toast.style.opacity = '0'; |
|
setTimeout(() => { |
|
if (toast.parentNode) { |
|
document.body.removeChild(toast); |
|
} |
|
}, 300); |
|
}, 3000); |
|
}, 10); |
|
} |
|
|
|
let controlsLocked = false; |
|
function toggleControlsLock() { |
|
const container = document.getElementById('playerContainer'); |
|
controlsLocked = !controlsLocked; |
|
container.classList.toggle('controls-locked', controlsLocked); |
|
const icon = document.getElementById('lockIcon'); |
|
|
|
icon.innerHTML = controlsLocked |
|
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=\"M12 15v2m0-8V7a4 4 0 00-8 0v2m8 0H4v8h16v-8h-4z\"/>' |
|
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=\"M15 11V7a3 3 0 00-6 0v4m-3 4h12v6H6v-6z\"/>'; |
|
} |
|
</script> |
|
</body> |
|
</html> |
|
|