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

24
webplayer/.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# 忽略 Node.js 依赖目录Docker 镜像会自己安装
node_modules
# 忽略 npm 的调试日志
npm-debug.log
# 忽略操作系统生成的文件
.DS_Store
Thumbs.db
# 忽略开发工具和 IDE 的配置文件
.vscode
.idea
# 忽略本地开发脚本
start.bat
# 忽略其他不必要的文件
新建文本文档.txt
# Docker 相关文件自身不需要被包含进镜像
Dockerfile
.dockerignore
docker-compose.yml

68
webplayer/.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# Dependencies
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# VSCode directories/files
.vscode*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Optional eslint cache
.eslintcache
# dotenv environment variables file
.env
.env.test
# Mac files
.DS_Store
# Yarn Integrity file
.yarn-integrity

39
webplayer/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# ---- Stage 1: Build ----
# 选择一个包含 Node.js 的官方镜像。使用 LTS (长期支持) 版本以保证稳定性。
# slim 版本体积更小,适合生产环境。
FROM node:18-slim AS builder
# 在容器内创建一个工作目录
WORKDIR /usr/src/app
# 复制 package.json 和 package-lock.json 到工作目录
# 这样可以利用 Docker 的层缓存机制,只有当这两个文件变化时才会重新安装依赖
COPY package*.json ./
# 安装项目依赖
# 使用 --only=production 参数可以避免安装 devDependencies减小最终镜像体积
RUN npm install --only=production
# 复制所有项目文件到工作目录
COPY . .
# ---- Stage 2: Production ----
# 使用一个更轻量的基础镜像来运行应用
FROM node:18-slim
# 设置容器内的工作目录
WORKDIR /usr/src/app
# 从 builder 阶段复制已经安装好的 node_modules
COPY --from=builder /usr/src/app/node_modules ./node_modules
# 复制应用程序代码
COPY --from=builder /usr/src/app .
# 暴露应用程序运行的端口
# 这个端口号应该与您 server.js 中监听的端口一致,后续会通过环境变量传入
EXPOSE 8101
# 定义容器启动时执行的命令
# 使用 ["node", "server.js"] 而不是 "node server.js",这是更推荐的 exec 格式
CMD ["node", "server.js"]

View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
webplayer:
# 使用当前目录下的 Dockerfile 进行构建
build: .
# 为容器命名,方便管理
container_name: webplayer_app
# 设置容器在退出时总是自动重启,除非手动停止
restart: unless-stopped
ports:
# 将宿主机的 8101 端口映射到容器的 PORT 环境变量指定的端口
# 格式: "HOST:CONTAINER"
- "8101:${PORT:-8101}"
volumes:
# 将宿主机上的视频目录映射到容器内的 /videos 目录
# 请将 './your_videos_on_host' 替换为您宿主机上实际的视频目录路径
# 例如: 'D:/program/Short:/videos' (Windows)
# 或: '/path/to/your/videos:/videos' (Linux/macOS)
- "./your_videos_on_host:/videos"
# 将索引文件挂载出来,这样即使容器重建,索引也不会丢失
- "./series_index.json:/usr/src/app/series_index.json"
environment:
# --- 在这里配置您的环境变量 ---
# 端口号
- PORT=8101
# 容器内的视频目录,这个路径应与 volumes 中映射的容器路径一致
- VIDEOS_DIR=/videos
# 索引更新间隔(毫秒),例如 15 分钟
- UPDATE_INTERVAL_MS=900000
# 要扫描的子目录,多个目录用逗号分隔
- VALID_SUBDIRECTORIES=分集,剧集
# 您的 IYUU 密钥
- IYUU_KEY=123456789
# 设置 Node.js 运行环境为生产环境
- NODE_ENV=production
# 设置时区,确保容器内时间与您本地一致 (可选,但推荐)
- TZ=Asia/Shanghai

1282
webplayer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
webplayer/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "short",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js",
"dev": "nodemon --ignore series_index.json server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^5.1.0",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"dotenv": "^17.2.2",
"nodemon": "^3.1.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
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();
});

584
webplayer/public/style.css Normal file
View 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;
}
/* 在手机上变为单列 */}

234
webplayer/server.js Normal file
View File

@@ -0,0 +1,234 @@
// 引入 dotenv并执行 config()
// 这会让 Node.js 从 .env 文件中加载环境变量
require('dotenv').config();
const express = require('express');
const path = require('path');
const fs = require('fs');
const fsp = fs.promises; // 引入 fs.promises 以便使用 async/await
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const app = express();
// --- 1. 配置和全局变量 ---
// 从环境变量中读取配置,如果环境变量未设置,则使用默认值
const port = process.env.PORT || 8101;
// Docker 容器内的视频目录路径,将通过 Docker volumes 映射到宿主机上的实际目录
const videosDir = process.env.VIDEOS_DIR || '/videos';
const UPDATE_INTERVAL_MS = parseInt(process.env.UPDATE_INTERVAL_MS, 10) || 30 * 60 * 1000; // 默认30分钟
// 从环境变量读取有效的子目录列表,以逗号分隔,并去除空格
const VALID_SUBDIRECTORIES = process.env.VALID_SUBDIRECTORIES
? process.env.VALID_SUBDIRECTORIES.split(',').map(s => s.trim())
: ['分集'];
const IYUU_KEY = process.env.IYUU_KEY || "IYUU20053Tcb524cc74f1cbdc348e75a25124525282bd78bd6";
const indexFilePath = path.join(__dirname, 'series_index.json'); // 索引文件将存放在项目根目录
// 内存缓存,所有 /api/series 请求都将从这里以极速读取数据
let seriesCache = [];
// --- 2. 中间件 ---
app.use(express.json()); // 用于解析 /api/request 的 JSON body
// 托管 'public' 文件夹,用于前端文件
app.use(express.static('public'));
// 将 E:/videos/short 目录映射到 URL 的 /videos 路径下
app.use('/videos', express.static(videosDir));
// --- 3. 核心索引更新函数 (新增) ---
/**
* 异步扫描视频目录,生成剧集列表,更新缓存并写入 JSON 文件。
* 这个函数会优先在剧集根目录查找海报,如果找不到,则会继续搜索 VALID_SUBDIRECTORIES 中定义的子目录。
*/
async function updateAndCacheSeries() {
console.log('(后台任务)开始更新剧集索引...');
try {
const dirents = await fsp.readdir(videosDir, { withFileTypes: true });
const seriesPromises = dirents
.filter(dirent => dirent.isDirectory())
.map(async (dirent) => {
const seriesName = dirent.name;
const seriesPath = path.join(videosDir, seriesName);
const potentialPosters = ['poster.png', 'poster.jpg', '0.jpg', '0.png', "00.jpg", "poster.jpeg", "poster.webp", "0.jpeg", "00.png"];
// --- 核心修改:分步查找海报 ---
let posterRelativePath = null;
// 1. 优先在剧集根目录查找
for (const filename of potentialPosters) {
try {
await fsp.access(path.join(seriesPath, filename));
posterRelativePath = filename; // 路径就是文件名本身
break; // 找到了就跳出循环
} catch (e) { /* 文件不存在,继续 */ }
}
// 2. 如果根目录没找到,则到指定的子目录中查找
if (!posterRelativePath) {
try {
const itemsInSeries = await fsp.readdir(seriesPath, { withFileTypes: true });
const subdirsToScan = itemsInSeries
.filter(item => item.isDirectory() && VALID_SUBDIRECTORIES.includes(item.name))
.map(item => item.name);
// 使用 labeled loop 以便找到后能跳出所有循环
search_in_subdirs:
for (const subdir of subdirsToScan) {
for (const filename of potentialPosters) {
const posterFullPath = path.join(seriesPath, subdir, filename);
try {
await fsp.access(posterFullPath);
// 【关键】路径必须包含子目录,并使用'/'作为分隔符
posterRelativePath = path.posix.join(subdir, filename);
break search_in_subdirs; // 找到一个就彻底退出
} catch (e) { /* 文件不存在,继续 */ }
}
}
} catch(err) {
console.warn(`[警告] 扫描 ${seriesName} 的子目录时出错:`, err.message);
}
}
if (!posterRelativePath) {
console.warn(`[警告] 在 ${seriesName} 目录及其子目录中均未找到任何海报文件。`);
}
// 使用找到的相对路径构建最终URL
const posterUrl = posterRelativePath
// posterRelativePath 可能是 'poster.jpg' 或 '分集/poster.jpg'
? `./videos/${encodeURIComponent(seriesName)}/${posterRelativePath}`
: './default-poster.png'; // 备用默认海报
return {
name: seriesName,
posterUrl: posterUrl,
};
});
const newSeriesList = await Promise.all(seriesPromises);
newSeriesList.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
seriesCache = newSeriesList;
await fsp.writeFile(indexFilePath, JSON.stringify(seriesCache, null, 2), 'utf-8');
console.log(`(后台任务)索引更新成功!共找到 ${seriesCache.length} 个剧集。`);
} catch (err) {
console.error('(后台任务)更新索引时发生严重错误:', err);
}
}
// --- 4. API 路由 ---
// 您的心愿单请求API
app.post('/api/request', async (req, res) => {
const { dramaName } = req.body;
if (!dramaName || typeof dramaName !== 'string' || dramaName.trim() === '') {
return res.status(400).json({ message: '剧集名称不能为空' });
}
const IYUU_WEBHOOK_URL = `https://iyuu.cn/${IYUU_KEY}.send`;
const message = `收到想看剧集:${dramaName.trim()}`;
const targetUrl = `${IYUU_WEBHOOK_URL}?text=${encodeURIComponent(message)}`;
try {
console.log(`正在向 IYUU 推送心愿: ${dramaName}`);
const response = await fetch(targetUrl, { method: 'POST' });
if (!response.ok) {
const errorText = await response.text();
console.error(`Webhook 推送失败: ${response.status} - ${errorText}`);
throw new Error(`推送服务返回错误`);
}
res.status(200).json({ message: "心愿提交成功!" });
} catch (error) {
console.error("处理心愿请求时服务器内部错误:", error);
res.status(500).json({ message: "服务器在推送心愿时发生错误" });
}
});
// API: 获取短剧列表 (现在从内存缓存读取,极速响应)
app.get('/api/series', (req, res) => {
res.json(seriesCache);
});
// API: 获取某个短剧的视频文件列表 (改为 async/await代码更清晰)
app.get('/api/videos/:seriesName', async (req, res) => {
// --- 修改:不再在此处定义,而是使用全局常量 ---
// const validSubdirectories = ['分集', '剧集', 'episodes', 'vids'];
try {
const seriesName = decodeURIComponent(req.params.seriesName);
const seriesPath = path.join(videosDir, seriesName);
if (!seriesPath.startsWith(videosDir)) {
return res.status(403).send('非法访问');
}
let allVideoFiles = [];
const dirents = await fsp.readdir(seriesPath, { withFileTypes: true });
const rootVideoFiles = dirents
.filter(dirent => dirent.isFile() && ['.mp4', '.webm', '.mov', '.mkv', '.avi'].includes(path.extname(dirent.name).toLowerCase()))
.map(dirent => dirent.name);
allVideoFiles.push(...rootVideoFiles);
// --- 修改:使用全局常量 VALID_SUBDIRECTORIES ---
const subdirsToScan = dirents
.filter(dirent => dirent.isDirectory() && VALID_SUBDIRECTORIES.includes(dirent.name))
.map(dirent => dirent.name);
const subdirVideoPromises = subdirsToScan.map(async (subdirName) => {
const subdirPath = path.join(seriesPath, subdirName);
const filesInSubdir = await fsp.readdir(subdirPath);
return filesInSubdir
.filter(file => ['.mp4', '.webm', '.mov', '.mkv', '.avi'].includes(path.extname(file).toLowerCase()))
.map(videoFile => path.posix.join(subdirName, videoFile));
});
const nestedVideoArrays = await Promise.all(subdirVideoPromises);
allVideoFiles.push(...nestedVideoArrays.flat());
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
allVideoFiles.sort(collator.compare);
res.json(allVideoFiles);
} catch (err) {
console.error(`读取短剧 "${req.params.seriesName}" 目录时出错:`, err);
if (err.code === 'ENOENT') {
return res.status(404).send('短剧不存在');
}
res.status(500).send('服务器内部错误');
}
});
// --- 5. 服务器启动逻辑 (新增的启动模式) ---
(async () => {
// 步骤 A: 尝试从文件加载现有索引,实现快速启动,避免冷启动时列表为空
try {
const indexData = await fsp.readFile(indexFilePath, 'utf-8');
seriesCache = JSON.parse(indexData);
console.log(`成功从索引文件加载了 ${seriesCache.length} 条剧集数据到缓存。`);
} catch (err) {
console.log('未找到现有索引文件或加载失败。将在后台立即创建新的索引。');
}
// 步骤 B: 启动 Express 服务器,使其可以立即响应请求
app.listen(port, () => {
// 【保持您的日志风格】
console.log(`Server running on http://localhost:${port}`);
console.log(`Frontend served from 'public' directory.`);
console.log(`Videos & Posters served from '${videosDir}' and mapped to '/videos' URL.`);
});
// 步骤 C: 在后台立即执行一次完整的索引更新,以获取最新数据
await updateAndCacheSeries();
// 步骤 D: 设置定时器,定期在后台更新索引
setInterval(updateAndCacheSeries, UPDATE_INTERVAL_MS);
})();