TV / player.html
samlax12's picture
Upload 30 files
7eff83b verified
<!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; // 跟踪当前HLS实例
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() {
// 解析URL参数
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'); // 新增:从URL获取集数信息
// 从localStorage获取数据
currentVideoTitle = title || localStorage.getItem('currentVideoTitle') || '未知视频';
currentEpisodeIndex = index;
// 设置自动连播开关状态
autoplayEnabled = localStorage.getItem('autoplayEnabled') !== 'false'; // 默认为true
document.getElementById('autoplayToggle').checked = autoplayEnabled;
// 获取广告过滤设置
adFilteringEnabled = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true
// 监听自动连播开关变化
document.getElementById('autoplayToggle').addEventListener('change', function(e) {
autoplayEnabled = e.target.checked;
localStorage.setItem('autoplayEnabled', autoplayEnabled);
});
// 优先使用URL传递的集数信息,否则从localStorage获取
try {
if (episodesList) {
// 如果URL中有集数数据,优先使用它
currentEpisodes = JSON.parse(decodeURIComponent(episodesList));
console.log('从URL恢复集数信息:', currentEpisodes.length);
} else {
// 否则从localStorage获取
currentEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');
console.log('从localStorage恢复集数信息:', currentEpisodes.length);
}
// 检查集数索引是否有效,如果无效则调整为0
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;
}
// 更新URL以反映修正后的索引
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);
// 尝试从URL参数中恢复播放位置
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();
}
});
// 新增:视频暂停时也保存
// 需确保 dp.video 已初始化
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) { // 每5秒最多保存一次
saveCurrentProgress();
lastSave = now;
}
});
clearInterval(waitForVideo);
}
}, 200);
});
// 处理键盘快捷键
function handleKeyboardShortcuts(e) {
// 忽略输入框中的按键事件
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Alt + 左箭头 = 上一集
if (e.altKey && e.key === 'ArrowLeft') {
if (currentEpisodeIndex > 0) {
playPreviousEpisode();
showShortcutHint('上一集', 'left');
e.preventDefault();
}
}
// Alt + 右箭头 = 下一集
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;
// 配置HLS.js选项
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
};
// 创建DPlayer实例
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, // 在Safari中启用AirPlay功能
chromecast: true, // 启用Chromecast投屏功能
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) {
// 清理之前的HLS实例
if (currentHls && currentHls.destroy) {
try {
currentHls.destroy();
} catch (e) {
console.warn('销毁旧HLS实例出错:', e);
}
}
// 创建新的HLS实例
const hls = new Hls(hlsConfig);
currentHls = hls;
// 跟踪是否已经显示错误
let errorDisplayed = false;
// 跟踪是否有错误发生
let errorCount = 0;
// 跟踪视频是否开始播放
let playbackStarted = false;
// 跟踪视频是否出现bufferAppendError
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) {
// 视频进度超过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++;
// 处理bufferAppendError
if (data.details === 'bufferAppendError') {
bufferAppendErrorCount++;
console.warn(`bufferAppendError 发生 ${bufferAppendErrorCount} 次`);
// 如果视频已经开始播放,则忽略这个错误
if (playbackStarted) {
console.log('视频已在播放中,忽略bufferAppendError');
return;
}
// 如果出现多次bufferAppendError但视频未播放,尝试恢复
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;
// 根据 tmp_time_add 调整 buffer 长度
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;
// 切换下次跳转时长:0.1 ↔ 5
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('视频播放失败,请检查视频源或网络连接');
});
// 添加seeking和seeked事件监听器,以检测用户是否在拖动进度条
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);
}
});
// 改进seeked事件处理
dp.on('seeked', function() {
// 如果视频跳转到了非常接近结尾的位置(小于0.3秒),且不是自然播放到此处
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);
}
}
// 延迟重置seeking标志,以便于区分自然播放结束和用户拖拽
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;
}
}
});
// 10秒后如果仍在加载,但不立即显示错误
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);
// 绑定原生全屏:DPlayer 触发全屏时调用 requestFullscreen
(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();
}
});
})();
}
// 自定义M3U8 Loader用于过滤广告
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
constructor(config) {
super(config);
const load = this.load.bind(this);
this.load = function(context, config, callbacks) {
// 拦截manifest和level请求
if (context.type === 'manifest' || context.type === 'level') {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = function(response, stats, context) {
// 如果是m3u8文件,处理内容以移除广告分段
if (response.data && typeof response.data === 'string') {
// 过滤掉广告段 - 实现更精确的广告过滤逻辑
response.data = filterAdsFromM3U8(response.data, true);
}
return onSuccess(response, stats, context);
};
}
// 执行原始load方法
load(context, config, callbacks);
};
}
}
// M3U8清单广告过滤函数
function filterAdsFromM3U8(m3u8Content, strictMode = false) {
if (!m3u8Content) return '';
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 只过滤#EXT-X-DISCONTINUITY标识
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) {
// 确保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; // 重置视频结束标志
// 获取当前URL参数,保留source参数
const urlParams = new URLSearchParams(window.location.search);
const sourceName = urlParams.get('source') || '';
// 更新URL,不刷新页面,保留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);
}
// 更新UI
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
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() {
// 查找DPlayer的进度条元素
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)}`);
// 阻止默认事件传播,避免DPlayer内部逻辑将视频跳至末尾
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() {
// 确保 currentEpisodes 非空
if (!currentEpisodes || currentEpisodes.length === 0) {
console.warn('没有可用的剧集列表,无法保存完整的历史记录');
}
// 尝试从URL中获取参数
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,使用标题作为唯一标识符
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] : []
};
// 如果外部定义了addToViewingHistory函数,则调用它
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) {
// 存在则更新现有记录的集数、时间戳和URL
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;
// 同时更新URL以保存当前的集数状态
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 {
// 添加新记录到最前面,但保存完整URL以便能直接打开到正确的集数
videoInfo.url = window.location.href;
console.log(`创建新的历史记录: "${currentVideoTitle}", ${currentEpisodes.length}集`);
history.unshift(videoInfo);
}
// 限制历史记录数量为50条
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');
// 3秒后隐藏
setTimeout(() => {
hint.classList.remove('show');
setTimeout(() => hint.remove(), 300);
}, 3000);
}, 100);
}
// 格式化时间为 mm:ss 格式
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);
}
// 每30秒保存一次播放进度
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;
// 在localStorage中保存进度
const progressKey = `videoProgress_${getVideoId()}`;
const progressData = {
position: currentTime,
duration: duration,
timestamp: Date.now()
};
try {
localStorage.setItem(progressKey, JSON.stringify(progressData));
// --- 新增:同步更新 viewingHistory 中的进度 ---
try {
const historyRaw = localStorage.getItem('viewingHistory');
if (historyRaw) {
const history = JSON.parse(historyRaw);
// 用 title + 集数索引唯一标识
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) {
// 忽略 viewingHistory 更新错误
}
} 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}`;
}
// 简单的Toast消息提示函数
function showToast(message, type = 'error') {
// 如果已有Toast,先移除它
const existingToast = document.getElementById('custom-toast');
if (existingToast) {
document.body.removeChild(existingToast);
}
// 创建新的Toast元素
const toast = document.createElement('div');
toast.id = 'custom-toast';
// 设置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内容
toast.textContent = message;
// 添加到页面
document.body.appendChild(toast);
// 显示Toast
setTimeout(() => {
toast.style.opacity = '1';
// 3秒后隐藏并移除
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>