This commit is contained in:
DengDai
2025-12-08 14:45:14 +08:00
commit 519589f8f5
60 changed files with 8191 additions and 0 deletions

467
webplayer/public/script.js Normal file
View File

@@ -0,0 +1,467 @@
document.addEventListener('DOMContentLoaded', () => {
// =================================================================
// 1. DOM 元素获取 (结构清晰,无需改动)
// =================================================================
const seriesGrid = document.getElementById('series-grid');
const searchInput = document.getElementById('search-input');
const modalBackdrop = document.getElementById('modal-backdrop');
const allModals = document.querySelectorAll('.modal, .video-modal');
const detailsModal = document.getElementById('details-modal');
const detailTitle = document.getElementById('detail-title');
const episodeList = document.getElementById('episode-list');
const videoModal = document.getElementById('video-modal');
const videoPlayer = document.getElementById('video-player');
const playerTitle = document.getElementById('player-title');
const prevBtn = document.getElementById('prev-episode-btn');
const nextBtn = document.getElementById('next-episode-btn');
const playerEpisodesContainer = document.getElementById('player-episodes');
const requestModal = document.getElementById('request-modal');
const requestForm = document.getElementById('request-form');
const requestInput = document.getElementById('request-input');
const requestStatus = document.getElementById('request-status');
const noResultsContainer = document.getElementById('no-results-container');
const showRequestModalBtn = document.getElementById('show-request-modal-btn');
const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal');
const historyList = document.getElementById('history-list');
// =================================================================
// 2. 应用状态变量
// =================================================================
let allSeriesData = []; // 存储所有剧集的基础信息 { name, posterUrl },剧集列表会按需加载
let activeSeriesData = null; // 当前正在操作的剧集数据 { name, episodes }
let currentEpisodeIndex = -1; // 当前播放的集数索引
let watchHistory = {}; // 从 localStorage 加载的播放历史
const HISTORY_KEY = 'WatchHistory';
// =================================================================
// 3. 核心功能函数
// =================================================================
/**
* 异步获取指定剧集的剧集列表。
* 内置缓存:如果已获取过,则直接从 allSeriesData 返回避免重复API请求。
* @param {string} seriesName - 剧集名称
* @returns {Promise<Array<string>>} - 包含剧集文件名的数组
*/
const fetchEpisodesForSeries = async (seriesName) => {
const seriesInfo = allSeriesData.find(s => s.name === seriesName);
if (!seriesInfo) throw new Error(`在本地数据中未找到剧集: "${seriesName}"`);
// 如果已经获取过剧集列表,直接返回缓存的数据
if (seriesInfo.episodes) {
return seriesInfo.episodes;
}
try {
const response = await fetch(`./api/videos/${encodeURIComponent(seriesName)}`);
if (!response.ok) throw new Error(`API 请求失败,状态码: ${response.status}`);
const episodes = await response.json();
// 缓存结果到主数据对象中
seriesInfo.episodes = episodes;
return episodes;
} catch (error) {
console.error(`获取剧集 "${seriesName}" 失败:`, error);
// 将错误向上抛出,由调用者处理用户提示
throw error;
}
};
/**
* 渲染主页的剧集网格。
* @param {Array<object>} seriesArray - 需要渲染的剧集数组
*/
const renderSeriesGrid = (seriesArray) => {
seriesGrid.innerHTML = '';
if (seriesArray.length === 0) return;
seriesArray.forEach(series => {
const card = document.createElement('div');
card.className = 'series-card';
card.dataset.seriesName = series.name;
card.innerHTML = `
<div class="series-card__thumbnail">
<img data-src="${series.posterUrl}" data-series-name="${series.name}" alt="${series.name} Poster" class="lazy">
</div>
<div class="series-card__info">
<h3>${series.name}</h3>
</div>
`;
seriesGrid.appendChild(card);
});
setupLazyLoader();
};
/**
* 播放指定索引的剧集,并处理续播逻辑。
* @param {number} episodeIndex - 剧集索引
*/
const playVideo = (episodeIndex) => {
if (!activeSeriesData || episodeIndex < 0 || episodeIndex >= activeSeriesData.episodes.length) {
console.error("无法播放:剧集数据无效或索引越界");
return;
}
currentEpisodeIndex = episodeIndex;
const { name: seriesName, episodes } = activeSeriesData;
const episodeFilename = episodes[episodeIndex];
const readableTitle = episodeFilename.replace(/\.[^/.]+$/, "");
playerTitle.textContent = readableTitle;
videoPlayer.src = `./videos/${encodeURIComponent(seriesName)}/${encodeURIComponent(episodeFilename)}`;
// play() 返回一个 Promise处理自动播放被浏览器阻止的情况
videoPlayer.play().then(() => {
// 检查是否有播放历史且不是已看完状态,用于续播
const history = watchHistory[seriesName];
if (history && history.lastIndex === episodeIndex && !history.finished) {
// 在视频总时长的合理范围内才续播
if (history.lastTime > 0 && history.lastTime < videoPlayer.duration - 5) {
videoPlayer.currentTime = history.lastTime;
}
}
}).catch(error => console.error("视频播放失败:", error));
updateNavButtons();
populatePlayerEpisodeList();
openModal(videoModal);
};
// =================================================================
// 4. 辅助函数 (UI, History, etc.)
// =================================================================
/**
* 创建一个剧集列表项的 DOM 元素。
* @param {string} episode - 剧集文件名
* @param {number} index - 剧集索引
* @param {string} seriesName - 所属剧集名称
* @param {string} itemClass - 元素的 CSS class
* @returns {HTMLElement} - 创建的 div 元素
*/
const createEpisodeListItem = (episode, index, seriesName, itemClass) => {
const isWatched = isEpisodeWatched(seriesName, index);
const item = document.createElement('div');
item.className = itemClass;
item.dataset.index = index;
if (isWatched) item.classList.add('watched');
item.innerHTML = `
<span>${episode.replace(/\.[^/.]+$/, "")}</span>
${isWatched ? '<span class="watched-check">✅</span>' : ''}
`;
return item;
};
const openModal = (modal) => {
modalBackdrop.classList.add('active');
modal.classList.add('active');
document.body.classList.add('modal-open');
};
const closeModal = () => {
modalBackdrop.classList.remove('active');
allModals.forEach(modal => modal.classList.remove('active'));
document.body.classList.remove('modal-open');
// 关闭弹窗时暂停视频并清空src防止后台播放和资源占用
if (!videoPlayer.paused) {
videoPlayer.pause();
}
videoPlayer.src = "";
};
const loadHistory = () => watchHistory = JSON.parse(localStorage.getItem(HISTORY_KEY)) || {};
const saveHistory = () => localStorage.setItem(HISTORY_KEY, JSON.stringify(watchHistory));
const recordPlaybackTime = (time) => {
if (!activeSeriesData || currentEpisodeIndex < 0) return;
const seriesName = activeSeriesData.name;
watchHistory[seriesName] = { lastIndex: currentEpisodeIndex, lastTime: time, finished: false };
saveHistory();
};
const markEpisodeAsFinished = (seriesName, episodeIndex) => {
watchHistory[seriesName] = { lastIndex: episodeIndex, lastTime: 0, finished: true };
saveHistory();
};
/**
* 检查一集是否被视为“已观看”。
* 条件1. 历史记录中的索引大于当前集2. 历史记录索引等于当前集且标记为finished。
* @returns {boolean}
*/
const isEpisodeWatched = (seriesName, episodeIndex) => {
const history = watchHistory[seriesName];
if (!history) return false;
return history.lastIndex > episodeIndex || (history.lastIndex === episodeIndex && history.finished);
};
const formatTime = (seconds) => {
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};
const populateHistoryModal = async () => {
historyList.innerHTML = '';
const historyEntries = Object.entries(watchHistory);
if (historyEntries.length === 0) {
historyList.innerHTML = '<p style="text-align: center; color: #888; padding: 20px 0;">暂无播放记录</p>';
return;
}
// 按剧集名称排序
historyEntries.sort((a, b) => a[0].localeCompare(b[0], 'zh-CN'));
// 使用 for...of 循环以正确处理 await
for (const [seriesName, history] of historyEntries) {
try {
// 此处无需关心剧集是否已获取fetchEpisodesForSeries 会处理缓存
const episodes = await fetchEpisodesForSeries(seriesName);
if (history.lastIndex >= episodes.length) {
console.warn(`剧集 "${seriesName}" 历史记录越界,跳过。`);
continue;
}
const episodeFilename = episodes[history.lastIndex];
const readableEpisodeName = episodeFilename.replace(/\.[^/.]+$/, "");
const progressText = history.finished
? `已看完: ${readableEpisodeName}`
: `看到: ${readableEpisodeName} (${formatTime(history.lastTime)})`;
const item = document.createElement('div');
item.className = 'history-item';
item.dataset.seriesName = seriesName;
item.dataset.episodeIndex = history.lastIndex;
item.innerHTML = `<span class="series-title">${seriesName}</span><span class="episode-name">${progressText}</span>`;
historyList.appendChild(item);
} catch (error) {
// 如果 fetchEpisodesForSeries 失败 (例如剧集已被删除),则跳过此条目
console.error(`处理历史记录中的 "${seriesName}" 时出错:`, error.message);
continue;
}
}
if (historyList.childElementCount === 0) {
historyList.innerHTML = '<p style="text-align: center; color: #888; padding: 20px 0;">有播放记录,但未能匹配到现有剧集。</p>';
}
};
const updateNavButtons = () => {
prevBtn.disabled = currentEpisodeIndex <= 0;
nextBtn.disabled = currentEpisodeIndex >= activeSeriesData.episodes.length - 1;
};
const populatePlayerEpisodeList = () => {
playerEpisodesContainer.innerHTML = '';
const { name: seriesName, episodes } = activeSeriesData;
episodes.forEach((episode, index) => {
const item = createEpisodeListItem(episode, index, seriesName, 'player-episode-item');
if (index === currentEpisodeIndex) item.classList.add('active');
playerEpisodesContainer.appendChild(item);
});
// 自动滚动到当前播放的剧集
const activeItem = playerEpisodesContainer.querySelector('.active');
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
function setupLazyLoader() {
const lazyImages = document.querySelectorAll('img.lazy');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
// 图片加载失败时,显示剧集名称首字母作为占位符
img.onerror = () => {
img.parentElement.innerHTML = `<div class="series-card__thumbnail-placeholder"><span>${img.dataset.seriesName.charAt(0)}</span></div>`;
};
img.classList.remove('lazy');
observer.unobserve(img);
}
});
}, { rootMargin: '0px 0px 150px 0px' }); // 预加载视口下方150px的图片
lazyImages.forEach(img => imageObserver.observe(img));
}
// 通用节流函数,防止事件过于频繁触发
const throttle = (func, limit) => {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
};
// =================================================================
// 5. 事件处理 (使用事件委托提高性能)
// =================================================================
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase().trim();
const filteredSeries = allSeriesData.filter(series => series.name.toLowerCase().includes(searchTerm));
renderSeriesGrid(filteredSeries);
noResultsContainer.style.display = (filteredSeries.length === 0 && searchTerm) ? 'block' : 'none';
});
showRequestModalBtn.addEventListener('click', () => {
requestInput.value = searchInput.value.trim(); // 自动填充搜索词
openModal(requestModal);
requestInput.focus();
});
requestForm.addEventListener('submit', async (e) => {
e.preventDefault();
const dramaName = requestInput.value.trim();
if (!dramaName) return;
try {
const response = await fetch('./api/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ dramaName: dramaName }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '服务器返回了一个未知错误');
}
requestStatus.textContent = result.message;
requestStatus.className = 'request-status success';
requestInput.value = '';
setTimeout(() => {
closeModal();
setTimeout(() => {
requestStatus.textContent = '';
requestStatus.className = 'request-status';
}, 500);
}, 2000);
} catch (error) {
console.error('提交心愿失败:', error);
requestStatus.textContent = `出错了: ${error.message}`;
requestStatus.className = 'request-status error';
}
});
historyBtn.addEventListener('click', () => {
populateHistoryModal();
openModal(historyModal);
});
// -- 使用事件委托统一处理所有动态生成的列表项的点击事件
// 处理剧集卡片点击
seriesGrid.addEventListener('click', async (e) => {
const card = e.target.closest('.series-card');
if (!card) return;
const seriesName = card.dataset.seriesName;
try {
const episodes = await fetchEpisodesForSeries(seriesName);
activeSeriesData = { name: seriesName, episodes };
detailTitle.textContent = seriesName;
episodeList.innerHTML = '';
episodes.forEach((ep, index) => {
const item = createEpisodeListItem(ep, index, seriesName, 'episode-item');
episodeList.appendChild(item);
});
openModal(detailsModal);
} catch (error) {
alert(`加载 "${seriesName}" 失败,请稍后重试。`);
}
});
// 处理剧集详情列表点击
episodeList.addEventListener('click', (e) => {
const item = e.target.closest('.episode-item');
if (item) {
closeModal();
playVideo(parseInt(item.dataset.index, 10));
}
});
// 处理播放器内剧集列表点击
playerEpisodesContainer.addEventListener('click', (e) => {
const item = e.target.closest('.player-episode-item');
if (item) {
playVideo(parseInt(item.dataset.index, 10));
}
});
// 处理历史记录列表点击
historyList.addEventListener('click', async (e) => {
const item = e.target.closest('.history-item');
if (!item) return;
const seriesName = item.dataset.seriesName;
const episodeIndex = parseInt(item.dataset.episodeIndex, 10);
try {
const episodes = await fetchEpisodesForSeries(seriesName);
activeSeriesData = { name: seriesName, episodes };
closeModal();
playVideo(episodeIndex);
} catch (error) {
alert('找不到该剧集,可能已被移除。');
}
});
// -- 视频播放器事件
videoPlayer.addEventListener('timeupdate', throttle(() => recordPlaybackTime(videoPlayer.currentTime), 5000));
videoPlayer.addEventListener('ended', () => {
markEpisodeAsFinished(activeSeriesData.name, currentEpisodeIndex);
// 自动播放下一集
if (currentEpisodeIndex < activeSeriesData.episodes.length - 1) {
playVideo(currentEpisodeIndex + 1);
} else {
alert('全剧终!');
closeModal();
}
});
// -- 通用关闭事件
modalBackdrop.addEventListener('click', closeModal);
document.querySelectorAll('[data-close-modal]').forEach(btn => btn.addEventListener('click', closeModal));
// -- 播放器导航按钮
prevBtn.addEventListener('click', () => playVideo(currentEpisodeIndex - 1));
nextBtn.addEventListener('click', () => playVideo(currentEpisodeIndex + 1));
// =================================================================
// 6. 初始化
// =================================================================
async function initializeApp() {
loadHistory();
try {
const response = await fetch('./api/series');
if (!response.ok) throw new Error(`网络错误: ${response.statusText}`);
allSeriesData = await response.json();
renderSeriesGrid(allSeriesData);
historyBtn.disabled = false; // 数据加载成功后才启用历史记录按钮
} catch (error) {
console.error('初始化失败:', error);
seriesGrid.innerHTML = '<p class="error-message">加载剧集列表失败,请检查网络或刷新页面。</p>';
}
}
initializeApp();
});