Files
Skit-Panel/webplayer/public/script.js
DengDai 519589f8f5 init
2025-12-08 14:45:14 +08:00

468 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});