init
This commit is contained in:
BIN
webplayer/public/default-poster.png
Normal file
BIN
webplayer/public/default-poster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 MiB |
BIN
webplayer/public/favicon.png
Normal file
BIN
webplayer/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
webplayer/public/fonts/NotoSansSC-Bold.ttf
Normal file
BIN
webplayer/public/fonts/NotoSansSC-Bold.ttf
Normal file
Binary file not shown.
BIN
webplayer/public/fonts/NotoSansSC-Medium.ttf
Normal file
BIN
webplayer/public/fonts/NotoSansSC-Medium.ttf
Normal file
Binary file not shown.
BIN
webplayer/public/fonts/NotoSansSC-Regular.ttf
Normal file
BIN
webplayer/public/fonts/NotoSansSC-Regular.ttf
Normal file
Binary file not shown.
97
webplayer/public/index.html
Normal file
97
webplayer/public/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系列剧集播放器</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="./favicon.png" sizes="128x128">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<div>
|
||||
<h1>精选剧集</h1>
|
||||
<p>选择一个系列,开始观看</p>
|
||||
</div>
|
||||
<button id="history-btn" class="header-btn" disabled>播放记录</button>
|
||||
</div>
|
||||
</header>
|
||||
<form id="search-form" class="search-container">
|
||||
<input type="search" id="search-input" placeholder="搜索剧集名称...">
|
||||
</form>
|
||||
<main>
|
||||
<div id="series-grid" class="series-grid"></div>
|
||||
<div id="no-results-container" class="no-results" style="display: none;">
|
||||
<p>没有找到你想要的剧集...</p>
|
||||
<button id="show-request-modal-btn" class="btn-primary">告诉我你想看什么!</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 背景遮罩层 -->
|
||||
<div id="modal-backdrop" class="modal-backdrop"></div>
|
||||
|
||||
<!-- 剧集列表 Modal -->
|
||||
<div id="details-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 id="detail-title">剧集列表</h2>
|
||||
<div class="episode-scroll-container">
|
||||
<div id="episode-list"></div>
|
||||
</div>
|
||||
<button class="btn-close" data-close-modal>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交心愿 Modal -->
|
||||
<div id="request-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>想看新剧?告诉我!</h3>
|
||||
<p>我们会尽快寻找并上架您想看的剧集。</p>
|
||||
<form id="request-form">
|
||||
<input type="text" id="request-input" placeholder="输入你想看的剧集名称" required>
|
||||
<button type="submit">提交心愿</button>
|
||||
</form>
|
||||
<p id="request-status" class="request-status"></p>
|
||||
<button class="btn-close" data-close-modal style="margin-top: 15px;">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>播放记录</h2>
|
||||
<p>这里记录了您每个系列最近一次的观看进度。</p>
|
||||
<div class="episode-scroll-container">
|
||||
<div id="history-list"></div>
|
||||
</div>
|
||||
<button class="btn-close" data-close-modal>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频播放器 Modal -->
|
||||
<div id="video-modal" class="video-modal">
|
||||
<div class="player-layout-container">
|
||||
<div class="video-wrapper">
|
||||
<h3 id="player-title"></h3>
|
||||
<div class="video-container">
|
||||
<video id="video-player" controls controlslist="nodownload"></video>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
<button id="prev-episode-btn" class="player-nav-btn">上一集</button>
|
||||
<button id="next-episode-btn" class="player-nav-btn">下一集</button>
|
||||
</div>
|
||||
<button class="video-close-btn" data-close-modal>×</button>
|
||||
</div>
|
||||
<div id="player-episode-list" class="player-episode-list-container">
|
||||
<h4>正在播放系列</h4>
|
||||
<div class="episode-scroll-container">
|
||||
<div id="player-episodes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
467
webplayer/public/script.js
Normal file
467
webplayer/public/script.js
Normal 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();
|
||||
});
|
||||
584
webplayer/public/style.css
Normal file
584
webplayer/public/style.css
Normal file
@@ -0,0 +1,584 @@
|
||||
/* --- 1. 全局设置 & 字体定义 (无变动) --- */
|
||||
|
||||
@font-face {
|
||||
font-family:'Noto Sans SC';
|
||||
font-style:normal;
|
||||
font-weight:400;
|
||||
/* 常规 */
|
||||
src:url('./fonts/NotoSansSC-Regular.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'Noto Sans SC';
|
||||
font-style:normal;
|
||||
font-weight:500;
|
||||
/* 中等 */
|
||||
src:url('./fonts/NotoSansSC-Medium.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'Noto Sans SC';
|
||||
font-style:normal;
|
||||
font-weight:700;
|
||||
/* 粗体 */
|
||||
src:url('./fonts/NotoSansSC-Bold.ttf') format('truetype');
|
||||
}
|
||||
:root {
|
||||
--primary-color:#007bff;
|
||||
--dark-color:#212529;
|
||||
--light-color:#f8f9fa;
|
||||
--background-color:#eef2f7;
|
||||
--text-color:#333;
|
||||
--border-radius:12px;
|
||||
--shadow:0 10px 25px rgba(0,0,0,0.1);
|
||||
--transition-speed:0.3s;
|
||||
}
|
||||
* {
|
||||
box-sizing:border-box;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
body {
|
||||
font-family:'Noto Sans SC',sans-serif;
|
||||
line-height:1.7;
|
||||
background-color:var(--background-color);
|
||||
color:var(--text-color);
|
||||
}
|
||||
body.modal-open {
|
||||
overflow:hidden;
|
||||
}
|
||||
/* 【优化】修改 container 布局,使其成为页面中心卡片 */
|
||||
.container {
|
||||
max-width:960px;
|
||||
/* 减小最大宽度 */
|
||||
margin:40px auto;
|
||||
/* 上下边距,水平居中 */
|
||||
padding:30px;
|
||||
background-color:var(--light-color);
|
||||
/* 添加背景色以区分 */
|
||||
border-radius:var(--border-radius);
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
/* --- 2. 主页面 (Header,Grid,Search) --- */
|
||||
|
||||
.main-header {
|
||||
text-align:center;
|
||||
margin-bottom:30px;
|
||||
}
|
||||
.main-header h1 {
|
||||
font-size:2.5rem;
|
||||
color:var(--dark-color);
|
||||
font-weight:500;
|
||||
}
|
||||
.main-header p {
|
||||
font-size:1.1rem;
|
||||
color:#6c757d;
|
||||
}
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
white-space: nowrap; /* 防止按钮文字换行 */
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
.header-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
border-color: #ccc;
|
||||
color: #999;
|
||||
}
|
||||
/* 在 .modal-content p 样式之后添加 */
|
||||
.history-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.history-item:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
.history-item .series-title {
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.history-item .episode-name {
|
||||
color: #6c757d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.search-container {
|
||||
margin-bottom:2rem;
|
||||
}
|
||||
#search-input {
|
||||
width:100%;
|
||||
padding:12px 15px;
|
||||
font-size:1rem;
|
||||
color:#333;
|
||||
/* 文本颜色调整 */
|
||||
background-color:#fff;
|
||||
border:1px solid #ddd;
|
||||
border-radius:8px;
|
||||
outline:none;
|
||||
transition:border-color 0.3s ease,box-shadow 0.3s ease;
|
||||
}
|
||||
#search-input:focus {
|
||||
border-color:var(--primary-color);
|
||||
box-shadow:0 0 0 3px rgba(0,123,255,0.25);
|
||||
}
|
||||
#search-input::placeholder {
|
||||
color:#888;
|
||||
}
|
||||
/* 【新增】搜索无结果容器的样式 */
|
||||
.no-results {
|
||||
text-align:center;
|
||||
padding:40px 20px;
|
||||
background-color:#fff;
|
||||
border-radius:8px;
|
||||
margin-top:2rem;
|
||||
}
|
||||
.no-results p {
|
||||
margin:0 0 20px 0;
|
||||
font-size:1.1rem;
|
||||
color:#6c757d;
|
||||
}
|
||||
/* 【新增】通用主按钮样式 */
|
||||
.btn-primary {
|
||||
padding:12px 25px;
|
||||
font-size:1rem;
|
||||
font-weight:bold;
|
||||
color:#fff;
|
||||
background-color:var(--primary-color);
|
||||
border:none;
|
||||
border-radius:50px;
|
||||
cursor:pointer;
|
||||
transition:background-color 0.2s,transform 0.2s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color:#0056b3;
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
.series-grid {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(250px,1fr));
|
||||
/* 微调卡片最小宽度 */
|
||||
gap:25px;
|
||||
}
|
||||
/* --- 3. 系列卡片 (无变动,样式很棒) --- */
|
||||
|
||||
.series-card {
|
||||
background:white;
|
||||
border-radius:var(--border-radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
cursor:pointer;
|
||||
transition:transform var(--transition-speed) ease,box-shadow var(--transition-speed) ease;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
}
|
||||
.series-card:hover {
|
||||
transform:translateY(-8px);
|
||||
box-shadow:0 15px 30px rgba(0,0,0,0.15);
|
||||
}
|
||||
.series-card__thumbnail {
|
||||
aspect-ratio:2 / 3;
|
||||
background-color:#2a2a2e;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
overflow:hidden;
|
||||
}
|
||||
img.lazy {
|
||||
opacity:0;
|
||||
transition:opacity 0.3s ease-in-out;
|
||||
}
|
||||
img[src] {
|
||||
opacity:1;
|
||||
}
|
||||
.series-card__thumbnail img {
|
||||
width:100%;
|
||||
height:100%;
|
||||
object-fit:cover;
|
||||
}
|
||||
.series-card__thumbnail-placeholder {
|
||||
width:100%;
|
||||
height:100%;
|
||||
background-color:#343a40;
|
||||
color:white;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:4rem;
|
||||
font-weight:bold;
|
||||
user-select:none;
|
||||
}
|
||||
.series-card__info {
|
||||
padding:20px;
|
||||
flex-grow:1;
|
||||
}
|
||||
.series-card__info h3 {
|
||||
margin:0;
|
||||
font-size:1.2rem;
|
||||
font-weight:500;
|
||||
}
|
||||
/* --- 4. 通用弹窗 (Modal) --- */
|
||||
/* (无变动,结构良好) */
|
||||
.modal-backdrop {
|
||||
display:none;
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:rgba(0,0,0,0.7);
|
||||
backdrop-filter:blur(8px);
|
||||
-webkit-backdrop-filter:blur(8px);
|
||||
z-index:1000;
|
||||
opacity:0;
|
||||
transition:opacity var(--transition-speed) ease-in-out;
|
||||
}
|
||||
.modal-backdrop.active {
|
||||
display:block;
|
||||
opacity:1;
|
||||
}
|
||||
.modal,.video-modal {
|
||||
display:none;
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
z-index:1001;
|
||||
opacity:0;
|
||||
transition:opacity var(--transition-speed) ease-in-out;
|
||||
padding:20px;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
.modal.active,.video-modal.active {
|
||||
display:flex;
|
||||
opacity:1;
|
||||
}
|
||||
.modal-content,.player-layout-container {
|
||||
transform:scale(0.95);
|
||||
transition:transform var(--transition-speed) ease-in-out;
|
||||
}
|
||||
.modal.active .modal-content,.video-modal.active .player-layout-container {
|
||||
transform:scale(1);
|
||||
}
|
||||
/* --- 5. 剧集详情 & 心愿提交 Modal --- */
|
||||
|
||||
.modal-content {
|
||||
background:white;
|
||||
padding:30px;
|
||||
border-radius:var(--border-radius);
|
||||
box-shadow:var(--shadow);
|
||||
width:90%;
|
||||
max-width:500px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
max-height:80vh;
|
||||
}
|
||||
.modal-content h3,.modal-content #detail-title {
|
||||
text-align:center;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
.modal-content p {
|
||||
text-align:center;
|
||||
color:#6c757d;
|
||||
margin-bottom:20px;
|
||||
}
|
||||
/* 【优化】Modal 内的表单样式 */
|
||||
#request-form {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
/* 垂直排列 */
|
||||
gap:15px;
|
||||
width:100%;
|
||||
}
|
||||
#request-input {
|
||||
padding:12px 15px;
|
||||
font-size:1rem;
|
||||
border:1px solid #ccc;
|
||||
border-radius:6px;
|
||||
outline:none;
|
||||
}
|
||||
#request-form button {
|
||||
padding:12px 20px;
|
||||
font-size:1rem;
|
||||
font-weight:bold;
|
||||
color:#fff;
|
||||
background-color:var(--primary-color);
|
||||
border:none;
|
||||
border-radius:6px;
|
||||
cursor:pointer;
|
||||
transition:background-color 0.2s;
|
||||
}
|
||||
#request-form button:hover {
|
||||
background-color:#0056b3;
|
||||
}
|
||||
.request-status {
|
||||
margin-top:1rem;
|
||||
min-height:1.2em;
|
||||
font-weight:bold;
|
||||
text-align:center;
|
||||
}
|
||||
.request-status.success {
|
||||
color:#28a745;
|
||||
}
|
||||
.request-status.error {
|
||||
color:#dc3545;
|
||||
}
|
||||
.episode-scroll-container {
|
||||
overflow-y:auto;
|
||||
border:1px solid #ddd;
|
||||
border-radius:8px;
|
||||
flex-grow:1;
|
||||
min-height:0;
|
||||
}
|
||||
.episode-item,.player-episode-item {
|
||||
display:flex;
|
||||
/* 使用 flex 布局对齐 */
|
||||
justify-content:space-between;
|
||||
/* 让内容和对勾分开 */
|
||||
align-items:center;
|
||||
padding:15px 20px;
|
||||
border-bottom:1px solid #e9ecef;
|
||||
cursor:pointer;
|
||||
transition:background-color var(--transition-speed),color var(--transition-speed);
|
||||
}
|
||||
.episode-item:last-child,.player-episode-item:last-child {
|
||||
border-bottom:none;
|
||||
}
|
||||
.episode-item:hover,.player-episode-item:hover {
|
||||
background-color:var(--primary-color);
|
||||
color:white;
|
||||
}
|
||||
.player-episode-item.active {
|
||||
background-color:var(--primary-color);
|
||||
color:white;
|
||||
font-weight:500;
|
||||
}
|
||||
.episode-item.watched span:first-child {
|
||||
color:#888;
|
||||
text-decoration:line-through;
|
||||
}
|
||||
.watched-check {
|
||||
margin-left:auto;
|
||||
font-size:0.9em;
|
||||
padding-left:10px;
|
||||
}
|
||||
.btn-close {
|
||||
display:block;
|
||||
margin:20px auto 0;
|
||||
padding:10px 25px;
|
||||
background:#6c757d;
|
||||
/* 灰色调,不那么刺眼 */
|
||||
color:white;
|
||||
border:none;
|
||||
border-radius:50px;
|
||||
cursor:pointer;
|
||||
font-weight:500;
|
||||
transition:background-color var(--transition-speed),transform var(--transition-speed);
|
||||
flex-shrink:0;
|
||||
}
|
||||
.btn-close:hover {
|
||||
background-color:#5a6268;
|
||||
}
|
||||
/* --- 6. 视频播放器 (Video Player) (无变动) --- */
|
||||
.player-layout-container {
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:stretch;
|
||||
width:100%;
|
||||
max-width:1200px;
|
||||
height:90vh;
|
||||
gap:25px
|
||||
}
|
||||
.video-wrapper {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
position:relative;
|
||||
flex-grow:1;
|
||||
min-width:0
|
||||
}
|
||||
#player-title {
|
||||
color:#fff;
|
||||
text-align:center;
|
||||
margin-bottom:15px;
|
||||
font-weight:500;
|
||||
text-shadow:1px 1px 3px rgba(0,0,0,.5);
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
width:100%
|
||||
}
|
||||
.video-container {
|
||||
position:relative;
|
||||
background-color:#000;
|
||||
border-radius:8px;
|
||||
overflow:hidden;
|
||||
aspect-ratio:9/16;
|
||||
height:100%;
|
||||
max-height:calc(100% - 70px)
|
||||
}
|
||||
#video-player {
|
||||
width:100%;
|
||||
height:100%;
|
||||
display:block
|
||||
}
|
||||
.player-controls {
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
margin-top:15px;
|
||||
width:100%
|
||||
}
|
||||
.player-nav-btn {
|
||||
padding:10px 20px;
|
||||
background-color:rgba(255,255,255,.2);
|
||||
color:#fff;
|
||||
border:1px solid rgba(255,255,255,.4);
|
||||
border-radius:50px;
|
||||
cursor:pointer;
|
||||
transition:background-color .2s;
|
||||
flex-basis:48%
|
||||
}
|
||||
.player-nav-btn:hover:not(:disabled) {
|
||||
background-color:rgba(255,255,255,.4)
|
||||
}
|
||||
.player-nav-btn:disabled {
|
||||
opacity:.5;
|
||||
cursor:not-allowed
|
||||
}
|
||||
.video-close-btn {
|
||||
position:absolute;
|
||||
top:10px;
|
||||
right:10px;
|
||||
padding:0;
|
||||
width:30px;
|
||||
height:30px;
|
||||
line-height:30px;
|
||||
text-align:center;
|
||||
background:rgba(0,0,0,.5);
|
||||
font-size:1.2rem;
|
||||
z-index:10;
|
||||
border-radius:50%;
|
||||
color:#fff;
|
||||
border:none;
|
||||
cursor:pointer
|
||||
}
|
||||
.player-episode-list-container {
|
||||
display:none;
|
||||
background:#fff;
|
||||
border-radius:var(--border-radius);
|
||||
padding:20px;
|
||||
width:340px;
|
||||
flex-shrink:0;
|
||||
flex-direction:column
|
||||
}
|
||||
.player-episode-list-container h4 {
|
||||
margin-bottom:15px;
|
||||
text-align:center;
|
||||
font-weight:500;
|
||||
flex-shrink:0
|
||||
}
|
||||
/* --- 7. 响应式设计 (Media Queries) --- */
|
||||
/* (微调) */
|
||||
@media(min-width:801px) {
|
||||
.player-episode-list-container {
|
||||
display:flex
|
||||
}
|
||||
}@media(max-width:800px) {
|
||||
.video-modal {
|
||||
padding:0;
|
||||
background:#000
|
||||
}
|
||||
.player-layout-container {
|
||||
height:100%;
|
||||
max-height:100vh;
|
||||
padding:0
|
||||
}
|
||||
.video-wrapper {
|
||||
width:100%;
|
||||
height:100%;
|
||||
justify-content:space-between;
|
||||
padding:30px 15px
|
||||
}
|
||||
.video-container {
|
||||
max-height:none;
|
||||
height:auto;
|
||||
width:100%;
|
||||
flex-grow:1;
|
||||
border-radius:0
|
||||
}
|
||||
#player-title {
|
||||
order:1
|
||||
}
|
||||
.video-container {
|
||||
order:2
|
||||
}
|
||||
.player-controls {
|
||||
order:3
|
||||
}
|
||||
.video-close-btn {
|
||||
top:15px;
|
||||
right:15px
|
||||
}
|
||||
}@media (max-width:992px) {
|
||||
/* 调整 container 边距的断点 */
|
||||
.container {
|
||||
margin:20px;
|
||||
padding:20px;
|
||||
}
|
||||
}@media (max-width:768px) {
|
||||
.main-header h1 {
|
||||
font-size:2rem;
|
||||
}
|
||||
.series-grid {
|
||||
grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
|
||||
}
|
||||
/* 在平板上允许更小的卡片 */
|
||||
.modal-content {
|
||||
padding:20px;
|
||||
width:95%;
|
||||
}
|
||||
}@media (max-width:576px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.main-header {
|
||||
text-align: left;
|
||||
}
|
||||
.header-btn {
|
||||
align-self: flex-start; /* 按钮也靠左 */
|
||||
}
|
||||
.container {
|
||||
margin:15px;
|
||||
padding:15px;
|
||||
}
|
||||
.series-grid {
|
||||
grid-template-columns:1fr;
|
||||
gap:15px;
|
||||
}
|
||||
/* 在手机上变为单列 */}
|
||||
Reference in New Issue
Block a user