Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f09644125 | |||
| 72900de4ed | |||
| cc40f1920c | |||
| 23c052bc76 | |||
| e0adb66713 | |||
|
|
fce0e3d581 | ||
|
|
a33cf44de0 | ||
|
|
be953b1621 | ||
|
|
116db4cb0f | ||
|
|
97a339f08d | ||
|
|
888ca621e4 | ||
|
|
df5999c338 | ||
|
|
3b65c306de | ||
|
|
a6af3765a8 | ||
|
|
b8bd8a8d76 | ||
|
|
624a327246 | ||
|
|
5812b23f1c | ||
|
|
b76044ef0d | ||
|
|
7eec653233 | ||
|
|
9e4231a747 | ||
|
|
bae9f1dba5 |
66
README.md
66
README.md
@@ -1,27 +1,57 @@
|
||||
# Media Coding Web - Configuration & Run Guide
|
||||
|
||||
To properly test and run this project, you will need to prepare your environment:
|
||||
Media Coding Web is a high-performance video transcoding and streaming web application. It retrieves source media files from AWS S3 or MinIO, transcodes them on the fly utilizing hardware-accelerated video encoders, and streams them efficiently to the browser using HLS (m3u8).
|
||||
|
||||
1. **Install Node.js & FFmpeg**:
|
||||
- Ensure Node.js (v18+) is installed.
|
||||
- Install **FFmpeg** on your system and make sure it is available in your PATH environment variable. The Node.js library `fluent-ffmpeg` requires it.
|
||||
- If you plan to use Rockchip hardware encoding, make sure your FFmpeg build includes `h264_rkmpp` / `hevc_rkmpp` support and the device has the Rockchip MPP runtime available.
|
||||
- The service always uses Jellyfin FFmpeg by default: `/usr/lib/jellyfin-ffmpeg/ffmpeg` and `/usr/lib/jellyfin-ffmpeg/ffprobe`. You can override them with `JELLYFIN_FFMPEG_PATH` and `JELLYFIN_FFPROBE_PATH`.
|
||||
## Features
|
||||
|
||||
2. **AWS S3 / MinIO Configuration**:
|
||||
- Modify the `.env` file (copy from `.env.example`).
|
||||
- Add your `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `S3_BUCKET_NAME`.
|
||||
- If using **MinIO**, ensure you set the `S3_ENDPOINT` (e.g., `http://127.0.0.1:9000`) and set `S3_FORCE_PATH_STYLE=true`.
|
||||
- Ensure your bucket has `.mp4` video files.
|
||||
- **High-Performance HLS Streaming:** Streams video using HLS for fast startup, localized buffering, native browser video controls, and frame-accurate seeking.
|
||||
- **Hardware-Accelerated Encoding:** Detects and seamlessly supports Intel QSV, NVIDIA NVENC, and VAAPI hardware encoders natively via FFmpeg.
|
||||
- **AWS S3 / MinIO Integration:** Securely downloads media from S3-compatible endpoints to a dedicated local cache directory before processing.
|
||||
- **Persistent Sessions:** Utilizes Valkey (Redis) for stateful session management, eliminating repetitive login actions.
|
||||
- **Real-Time Progress:** Displays live feedback for both background S3 downloading and FFmpeg transcoding, communicated to the web application via WebSockets.
|
||||
- **Modern User Interface:** Features a sleek and highly responsive user interface complete with built-in light and dark mode switching.
|
||||
- **Centralized Log Storage:** Persists application activity and debugging events safely within the project's `/logs/` directory utilizing Winston loggers.
|
||||
- **Unified Configuration:** A single `.env` file powers the entire web application settings.
|
||||
|
||||
3. **Install Dependencies & Start**:
|
||||
## Prerequisites
|
||||
|
||||
To properly run and deploy this project, your environment should contain:
|
||||
|
||||
1. **Node.js**: Version 18.x or newer is required to natively support all APIs.
|
||||
2. **FFmpeg with Hardware Support**:
|
||||
- Ensure you configure an FFmpeg instance on your system.
|
||||
- For maximal performance, the application defaults to searching for `jellyfin-ffmpeg` located at `/usr/lib/jellyfin-ffmpeg/ffmpeg`. Feel free to point it at any other binary containing QSV or NVENC support by modifying the `JELLYFIN_FFMPEG_PATH` path inside `.env`.
|
||||
3. **Valkey (or Redis)**: An active Redis-compatible cache server string is required for user sessions.
|
||||
4. **AWS S3 / MinIO Account**: An object storage endpoint containing your encoded `.mp4` media catalog.
|
||||
|
||||
## Configuration & Installation
|
||||
|
||||
1. **Install Dependencies**:
|
||||
Navigate into the project root directory and execute:
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **Test Delivery**:
|
||||
- Open your browser to `http://localhost:3000`.
|
||||
- The application should display available `.mp4` items from your S3 bucket.
|
||||
- Click one video, and the Node server will begin to read the S3 stream and pipe it to FFmpeg, transcoding it into HLS segments inside the `/public/hls/` directory.
|
||||
- The frontend polls via `/api/status`, and once the index playlist is available, HLS playback starts!
|
||||
2. **Establish Environment Variables**:
|
||||
Create a pristine copy of the `.env.example` structure:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Open `.env` using your preferred editor to tailor your environment:
|
||||
- **Valkey/Redis Settings**: Configure `VALKEY_URL`.
|
||||
- **S3 Connectivity**: Insert your `S3_BUCKET_ADDRESS` and `AWS_REGION`. For MinIO specifically, fill the `S3_ENDPOINT` key (e.g. `http://127.0.0.1:9000`) and verify `S3_FORCE_PATH_STYLE=true`.
|
||||
|
||||
## Running the Application
|
||||
|
||||
To launch the web interface and application server, simply run:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
*(You may also run `npm run dev` to execute the local environment.)*
|
||||
|
||||
## Verifying Setup
|
||||
|
||||
1. Open a browser and load `http://localhost:3000` (or your defined `PORT`).
|
||||
2. Log into the system using your relevant S3 authorization if using local user mode.
|
||||
3. The server will communicate directly to your S3 bucket and load the application's root dashboard.
|
||||
4. Upon clicking any available video media, the server will rapidly download the required chunks to the local cache, start compiling HLS indexing data using native hardware encoding, and pipe an advanced native video stream back into your browser.
|
||||
|
||||
@@ -105,6 +105,7 @@ header h1 {
|
||||
header h1 span {
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@@ -136,35 +137,37 @@ header p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at top left, rgba(249, 250, 252, 0.95), rgba(255, 255, 255, 0.98));
|
||||
background: transparent;
|
||||
z-index: 50;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(480px, 100%);
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 24px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 20px 40px rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.75rem;
|
||||
color: #111827;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: #6b7280;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.login-card label {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
color: #4b5563;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -175,17 +178,17 @@ header p {
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-card input:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
.login-card .primary-btn {
|
||||
@@ -300,13 +303,6 @@ header p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-actions-row {
|
||||
display: block;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -315,6 +311,12 @@ header p {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
@@ -645,6 +647,50 @@ header p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.now-playing-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.subtitle-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.subtitle-panel label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.subtitle-panel select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.subtitle-panel select option {
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.now-playing p {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
@@ -1043,13 +1089,21 @@ header p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
.now-playing-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
@@ -1155,4 +1209,67 @@ header p {
|
||||
.control-seek {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* Mobile specific top bar */
|
||||
.user-controls {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-controls label {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#theme-selector {
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.user-controls .banner-actions,
|
||||
.user-controls .user-info {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-controls .action-btn {
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-top: 5rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.top-banner {
|
||||
margin-top: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,14 @@
|
||||
<label>页面主题:
|
||||
<select id="theme-selector">
|
||||
<option value="light">白天模式</option>
|
||||
<option value="dark">深夜模式</option>
|
||||
<option value="dark">黑夜模式</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="banner-actions">
|
||||
<button id="refresh-btn" class="action-btn" title="刷新列表">刷新列表</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span id="current-username"></span>
|
||||
<button id="logout-btn" class="action-btn">退出登录</button>
|
||||
<button id="logout-btn" class="action-btn danger">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,13 +57,7 @@
|
||||
<main class="dashboard">
|
||||
<section class="video-list-section glass-panel">
|
||||
<div class="section-header">
|
||||
<h2>Available Videos</h2>
|
||||
</div>
|
||||
<div class="section-actions-row">
|
||||
<div class="section-actions">
|
||||
<button id="refresh-btn" class="action-btn" title="刷新列表">刷新列表</button>
|
||||
<button id="clear-download-cache-btn" class="action-btn" title="清空下载缓存">清空下载缓存</button>
|
||||
</div>
|
||||
<h2 id="video-list-header">Available Videos</h2>
|
||||
</div>
|
||||
<div class="bucket-panel">
|
||||
<label for="bucket-select">Select Bucket</label>
|
||||
@@ -143,8 +139,19 @@
|
||||
<div id="now-playing" class="now-playing hidden">
|
||||
<h3>Now Playing</h3>
|
||||
<p id="current-video-title">video.mp4</p>
|
||||
<button id="transcode-btn" class="play-btn hidden">Start Transcode</button>
|
||||
<button id="stop-transcode-btn" class="play-btn stop-btn hidden">Stop Transcode</button>
|
||||
<div class="now-playing-actions">
|
||||
<div id="subtitle-panel" class="subtitle-panel hidden">
|
||||
<label for="subtitle-selector">字幕:</label>
|
||||
<select id="subtitle-selector">
|
||||
<option value="-1">无字幕</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="transcode-btn" class="play-btn hidden">开始播放</button>
|
||||
<button id="stop-transcode-btn" class="play-btn stop-btn hidden">停止播放</button>
|
||||
<button id="pre-slice-btn" class="play-btn stop-btn hidden">预切片 (HLS)</button>
|
||||
<button id="clear-playing-download-cache-btn" class="play-btn stop-btn hidden">清空下载缓存</button>
|
||||
<button id="clear-playing-transcode-cache-btn" class="play-btn stop-btn hidden">清空转码缓存</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -2,7 +2,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const videoListEl = document.getElementById('video-list');
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
const clearDownloadCacheBtn = document.getElementById('clear-download-cache-btn');
|
||||
const bucketSelect = document.getElementById('bucket-select');
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const appContainer = document.getElementById('app-container');
|
||||
@@ -19,9 +18,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const currentVideoTitle = document.getElementById('current-video-title');
|
||||
const transcodeBtn = document.getElementById('transcode-btn');
|
||||
const stopTranscodeBtn = document.getElementById('stop-transcode-btn');
|
||||
const preSliceBtn = document.getElementById('pre-slice-btn');
|
||||
const clearPlayingDownloadBtn = document.getElementById('clear-playing-download-cache-btn');
|
||||
const clearPlayingTranscodeBtn = document.getElementById('clear-playing-transcode-cache-btn');
|
||||
const themeSelector = document.getElementById('theme-selector');
|
||||
const currentUsername = document.getElementById('current-username');
|
||||
const videoListHeader = document.getElementById('video-list-header');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const subtitlePanel = document.getElementById('subtitle-panel');
|
||||
const subtitleSelector = document.getElementById('subtitle-selector');
|
||||
const topBannerTitle = document.getElementById('top-banner-title');
|
||||
const playBtn = document.getElementById('play-btn');
|
||||
const topBanner = document.getElementById('top-banner');
|
||||
@@ -142,6 +146,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const decoder = 'auto';
|
||||
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
||||
let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`;
|
||||
|
||||
const subIndex = subtitleSelector?.value;
|
||||
if (subIndex && subIndex !== '-1') {
|
||||
streamUrl += `&subtitleIndex=${encodeURIComponent(subIndex)}`;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (sessionId) {
|
||||
streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||
@@ -264,16 +274,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const subscribeToKey = (key) => {
|
||||
subscribedKey = key;
|
||||
let subscriptionKey = key;
|
||||
const subIndex = subtitleSelector?.value;
|
||||
if (subIndex && subIndex !== '-1') {
|
||||
subscriptionKey = `${key}-sub${subIndex}`;
|
||||
}
|
||||
|
||||
subscribedKey = subscriptionKey;
|
||||
if (wsConnected) {
|
||||
sendWsMessage({ type: 'subscribe', key });
|
||||
sendWsMessage({ type: 'subscribe', key: subscriptionKey });
|
||||
}
|
||||
};
|
||||
|
||||
const handleWsMessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.key !== currentVideoKey) return;
|
||||
if (message.key !== subscribedKey) return;
|
||||
|
||||
if (message.type === 'duration' && message.duration) {
|
||||
videoDuration = message.duration;
|
||||
@@ -380,7 +396,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (stopTranscodeBtn) {
|
||||
stopTranscodeBtn.classList.add('hidden');
|
||||
stopTranscodeBtn.disabled = false;
|
||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
||||
stopTranscodeBtn.textContent = '停止播放';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -462,6 +478,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
seekToTime(targetTime);
|
||||
};
|
||||
|
||||
const seekToTime = (targetTime) => {
|
||||
if (!isStreamActive || videoDuration <= 0) return;
|
||||
const clampedTime = Math.max(0, Math.min(targetTime, videoDuration));
|
||||
console.log(`[Seek] Seeking to ${clampedTime.toFixed(2)}s / ${videoDuration.toFixed(2)}s`);
|
||||
|
||||
if (hlsInstance) {
|
||||
// For HLS: seek within the current buffer if possible
|
||||
const relativeTime = clampedTime - seekOffset;
|
||||
if (relativeTime >= 0 && relativeTime <= (videoPlayer.duration || 0)) {
|
||||
videoPlayer.currentTime = relativeTime;
|
||||
} else {
|
||||
// Need server-side restart from new position
|
||||
seekOffset = clampedTime;
|
||||
const streamUrl = buildHlsPlaylistUrl();
|
||||
hlsInstance.destroy();
|
||||
hlsInstance = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 });
|
||||
hlsInstance.loadSource(streamUrl);
|
||||
hlsInstance.attachMedia(videoPlayer);
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
videoPlayer.play().catch(() => {});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
videoPlayer.currentTime = clampedTime;
|
||||
}
|
||||
updateSeekBarPosition(clampedTime);
|
||||
};
|
||||
|
||||
if (seekBar) {
|
||||
seekBar.addEventListener('mousedown', handleSeekStart);
|
||||
document.addEventListener('mousemove', handleSeekMove);
|
||||
@@ -586,7 +630,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
localStorage.setItem('sessionId', sessionId);
|
||||
if (username) {
|
||||
localStorage.setItem('username', username);
|
||||
if (currentUsername) currentUsername.textContent = username;
|
||||
if (videoListHeader) videoListHeader.textContent = `${username} Available Videos`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -674,26 +718,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearDownloadCache = async () => {
|
||||
if (!clearDownloadCacheBtn) return;
|
||||
clearDownloadCacheBtn.disabled = true;
|
||||
clearDownloadCacheBtn.textContent = '清空中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/clear-download-cache', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || '清空下载缓存失败');
|
||||
}
|
||||
alert('下载缓存已清空');
|
||||
} catch (err) {
|
||||
console.error('Clear download cache failed:', err);
|
||||
alert(`清空下载缓存失败: ${err.message}`);
|
||||
} finally {
|
||||
clearDownloadCacheBtn.disabled = false;
|
||||
clearDownloadCacheBtn.textContent = '清空下载缓存';
|
||||
}
|
||||
};
|
||||
|
||||
if (transcodeBtn) {
|
||||
transcodeBtn.addEventListener('click', () => {
|
||||
@@ -733,7 +758,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Build a tree structure from S3 keys
|
||||
const tree = {};
|
||||
data.videos.forEach(key => {
|
||||
data.videos.forEach(vid => {
|
||||
const key = vid.key;
|
||||
const parts = key.split('/');
|
||||
let current = tree;
|
||||
parts.forEach((part, index) => {
|
||||
@@ -742,15 +768,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
if (index === parts.length - 1) {
|
||||
current[part].__file = key;
|
||||
current[part].__hasTranscodeCache = vid.hasTranscodeCache;
|
||||
current[part].__hasDownloadCache = vid.hasDownloadCache;
|
||||
}
|
||||
current = current[part];
|
||||
});
|
||||
});
|
||||
|
||||
const createFileItem = (name, key) => {
|
||||
const createFileItem = (name, key, hasTranscodeCache, hasDownloadCache) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'video-item file-item';
|
||||
const ext = name.split('.').pop().toUpperCase();
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="video-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>
|
||||
@@ -762,16 +791,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
`;
|
||||
li.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectVideo(key, li);
|
||||
selectVideo(key, li, hasTranscodeCache, hasDownloadCache);
|
||||
});
|
||||
|
||||
return li;
|
||||
};
|
||||
|
||||
const renderTree = (node, container) => {
|
||||
for (const [name, value] of Object.entries(node)) {
|
||||
if (name === '__file') continue;
|
||||
if (name === '__file' || name === '__hasTranscodeCache' || name === '__hasDownloadCache') continue;
|
||||
|
||||
const childKeys = Object.keys(value).filter(key => key !== '__file');
|
||||
const childKeys = Object.keys(value).filter(k => k !== '__file' && k !== '__hasTranscodeCache' && k !== '__hasDownloadCache');
|
||||
const hasChildren = childKeys.length > 0;
|
||||
const isFile = typeof value.__file === 'string';
|
||||
|
||||
@@ -802,13 +832,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
li.appendChild(folderHeader);
|
||||
if (isFile) {
|
||||
subListContainer.appendChild(createFileItem(name, value.__file));
|
||||
subListContainer.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache));
|
||||
}
|
||||
renderTree(value, subListContainer);
|
||||
li.appendChild(subListContainer);
|
||||
container.appendChild(li);
|
||||
} else if (isFile) {
|
||||
container.appendChild(createFileItem(name, value.__file));
|
||||
container.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -821,7 +851,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
// Handle video selection
|
||||
const selectVideo = (key, listItemNode) => {
|
||||
const selectVideo = (key, listItemNode, hasTranscodeCache = false, hasDownloadCache = false) => {
|
||||
document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active'));
|
||||
listItemNode.classList.add('active');
|
||||
|
||||
@@ -847,37 +877,111 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (transcodeBtn) {
|
||||
transcodeBtn.disabled = false;
|
||||
transcodeBtn.textContent = 'Start Transcode';
|
||||
transcodeBtn.textContent = '开始播放';
|
||||
transcodeBtn.classList.remove('hidden');
|
||||
}
|
||||
if (stopTranscodeBtn) {
|
||||
stopTranscodeBtn.classList.add('hidden');
|
||||
stopTranscodeBtn.disabled = false;
|
||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
||||
stopTranscodeBtn.textContent = '停止播放';
|
||||
}
|
||||
|
||||
if (preSliceBtn) {
|
||||
preSliceBtn.classList.remove('hidden');
|
||||
preSliceBtn.disabled = false;
|
||||
}
|
||||
if (clearPlayingDownloadBtn) {
|
||||
if (hasDownloadCache) clearPlayingDownloadBtn.classList.remove('hidden');
|
||||
else clearPlayingDownloadBtn.classList.add('hidden');
|
||||
}
|
||||
if (clearPlayingTranscodeBtn) {
|
||||
if (hasTranscodeCache) clearPlayingTranscodeBtn.classList.remove('hidden');
|
||||
else clearPlayingTranscodeBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (playBtn) {
|
||||
playBtn.classList.add('hidden');
|
||||
}
|
||||
resetPhases();
|
||||
|
||||
// Reset subtitle selector before subscribing to ensure we use the base key for source download
|
||||
if (subtitleSelector) {
|
||||
subtitleSelector.innerHTML = '<option value="-1">无字幕</option>';
|
||||
subtitleSelector.value = "-1";
|
||||
}
|
||||
if (subtitlePanel) {
|
||||
subtitlePanel.classList.add('hidden');
|
||||
}
|
||||
|
||||
selectedKey = key;
|
||||
currentVideoKey = key;
|
||||
subscribeToKey(key);
|
||||
|
||||
nowPlaying.classList.remove('hidden');
|
||||
currentVideoTitle.textContent = key.split('/').pop();
|
||||
|
||||
// If download is needed, show the overlay so the user sees the progress
|
||||
if (!hasDownloadCache) {
|
||||
transcodingOverlay.classList.remove('hidden');
|
||||
showDownloadPhase();
|
||||
}
|
||||
|
||||
// Fetch subtitle metadata
|
||||
fetchVideoMetadata(selectedBucket, key);
|
||||
};
|
||||
|
||||
const handleSubtitleChange = () => {
|
||||
if (!selectedKey) return;
|
||||
subscribeToKey(selectedKey);
|
||||
};
|
||||
if (subtitleSelector) {
|
||||
subtitleSelector.addEventListener('change', handleSubtitleChange);
|
||||
}
|
||||
|
||||
const fetchVideoMetadata = async (bucket, key) => {
|
||||
if (!subtitlePanel || !subtitleSelector) return;
|
||||
|
||||
subtitlePanel.classList.add('hidden');
|
||||
subtitleSelector.innerHTML = '<option value="-1">正在加载字幕...</option>';
|
||||
subtitleSelector.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/video-metadata?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`, { headers: s3AuthHeaders });
|
||||
if (!res.ok) throw new Error('Failed to fetch metadata');
|
||||
const data = await res.json();
|
||||
|
||||
subtitleSelector.innerHTML = '<option value="-1">无字幕</option>';
|
||||
if (data.subtitleStreams && data.subtitleStreams.length > 0) {
|
||||
data.subtitleStreams.forEach(sub => {
|
||||
const option = document.createElement('option');
|
||||
option.value = sub.subIndex;
|
||||
option.textContent = `[${sub.language}] ${sub.title}`;
|
||||
subtitleSelector.appendChild(option);
|
||||
});
|
||||
subtitlePanel.classList.remove('hidden');
|
||||
} else {
|
||||
subtitleSelector.innerHTML = '<option value="-1">无嵌入字幕</option>';
|
||||
subtitlePanel.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch metadata failed:', err);
|
||||
subtitleSelector.innerHTML = '<option value="-1">无法加载字幕</option>';
|
||||
subtitlePanel.classList.remove('hidden');
|
||||
} finally {
|
||||
subtitleSelector.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startTranscode = async () => {
|
||||
if (!selectedKey) return;
|
||||
if (transcodeBtn) {
|
||||
transcodeBtn.disabled = true;
|
||||
transcodeBtn.textContent = 'Starting...';
|
||||
transcodeBtn.textContent = '播放中...';
|
||||
}
|
||||
if (stopTranscodeBtn) {
|
||||
stopTranscodeBtn.classList.remove('hidden');
|
||||
stopTranscodeBtn.disabled = false;
|
||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
||||
stopTranscodeBtn.textContent = '停止播放';
|
||||
}
|
||||
stopPolling();
|
||||
resetPhases();
|
||||
@@ -901,10 +1005,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
hlsInstance.loadSource(streamUrl);
|
||||
hlsInstance.attachMedia(videoPlayer);
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] MANIFEST_PARSED - starting playback');
|
||||
transcodingOverlay.classList.add('hidden');
|
||||
videoPlayer.classList.remove('hidden');
|
||||
isStreamActive = true;
|
||||
videoPlayer.play().catch(() => { });
|
||||
videoPlayer.play().catch((e) => {
|
||||
console.warn('[HLS] Autoplay blocked:', e.message);
|
||||
});
|
||||
showSeekBar();
|
||||
showCustomControls();
|
||||
updatePlayControls();
|
||||
@@ -913,15 +1020,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
schedulePlaybackChromeHide();
|
||||
});
|
||||
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
|
||||
console.error('[HLS] Error:', data.type, data.details, data.fatal ? '(FATAL)' : '', data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.warn('[HLS] Fatal network error, attempting recovery...');
|
||||
hlsInstance.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.warn('[HLS] Fatal media error, attempting recovery...');
|
||||
hlsInstance.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.error('[HLS] Fatal error, destroying instance');
|
||||
hlsInstance.destroy();
|
||||
break;
|
||||
}
|
||||
@@ -951,7 +1062,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const stopTranscode = async () => {
|
||||
if (!currentVideoKey || !stopTranscodeBtn) return;
|
||||
stopTranscodeBtn.disabled = true;
|
||||
stopTranscodeBtn.textContent = 'Stopping...';
|
||||
stopTranscodeBtn.textContent = '停止中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stop-transcode', {
|
||||
@@ -977,21 +1088,59 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updatePlayControls();
|
||||
if (transcodeBtn) {
|
||||
transcodeBtn.disabled = false;
|
||||
transcodeBtn.textContent = 'Start Transcode';
|
||||
transcodeBtn.textContent = '开始播放';
|
||||
transcodeBtn.classList.remove('hidden');
|
||||
}
|
||||
stopTranscodeBtn.classList.add('hidden');
|
||||
} catch (err) {
|
||||
console.error('Stop transcode failed:', err);
|
||||
alert(`Stop transcode failed: ${err.message}`);
|
||||
alert(`停止播放失败: ${err.message}`);
|
||||
} finally {
|
||||
if (stopTranscodeBtn) {
|
||||
stopTranscodeBtn.disabled = false;
|
||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
||||
stopTranscodeBtn.textContent = '停止播放';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const preSliceVideo = async () => {
|
||||
if (!selectedKey) return;
|
||||
preSliceBtn.disabled = true;
|
||||
preSliceBtn.textContent = '预切片中...';
|
||||
|
||||
try {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
const body = {
|
||||
bucket: selectedBucket,
|
||||
key: selectedKey,
|
||||
encoder: encoderSelect?.value || 'h264_rkmpp',
|
||||
decoder: 'auto',
|
||||
subtitleIndex: subtitleSelector?.value || '-1',
|
||||
sessionId
|
||||
};
|
||||
|
||||
const res = await fetch('/api/pre-slice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Pre-slice failed');
|
||||
|
||||
alert('已在后台开始预切片,您可以在进度条中查看进度。');
|
||||
} catch (err) {
|
||||
console.error('Pre-slice failed:', err);
|
||||
alert(`预切片失败: ${err.message}`);
|
||||
preSliceBtn.disabled = false;
|
||||
preSliceBtn.textContent = '预切片 (HLS)';
|
||||
}
|
||||
};
|
||||
|
||||
if (preSliceBtn) {
|
||||
preSliceBtn.addEventListener('click', preSliceVideo);
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (currentPollInterval) {
|
||||
clearInterval(currentPollInterval);
|
||||
@@ -1099,12 +1248,56 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Bind events
|
||||
refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket));
|
||||
if (clearDownloadCacheBtn) {
|
||||
clearDownloadCacheBtn.addEventListener('click', clearDownloadCache);
|
||||
}
|
||||
|
||||
if (stopTranscodeBtn) {
|
||||
stopTranscodeBtn.addEventListener('click', stopTranscode);
|
||||
}
|
||||
|
||||
if (clearPlayingDownloadBtn) {
|
||||
clearPlayingDownloadBtn.addEventListener('click', async () => {
|
||||
if (!currentVideoKey) return;
|
||||
clearPlayingDownloadBtn.disabled = true;
|
||||
clearPlayingDownloadBtn.textContent = '清空中...';
|
||||
try {
|
||||
const res = await fetch('/api/clear-video-download-cache', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: selectedBucket, key: currentVideoKey })
|
||||
});
|
||||
if (!res.ok) throw new Error('清空失败');
|
||||
clearPlayingDownloadBtn.classList.add('hidden');
|
||||
fetchVideos(selectedBucket);
|
||||
} catch (err) {
|
||||
alert('清空下载缓存失败: ' + err.message);
|
||||
} finally {
|
||||
clearPlayingDownloadBtn.disabled = false;
|
||||
clearPlayingDownloadBtn.textContent = '清空下载缓存';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (clearPlayingTranscodeBtn) {
|
||||
clearPlayingTranscodeBtn.addEventListener('click', async () => {
|
||||
if (!currentVideoKey) return;
|
||||
clearPlayingTranscodeBtn.disabled = true;
|
||||
clearPlayingTranscodeBtn.textContent = '清空中...';
|
||||
try {
|
||||
const res = await fetch('/api/clear-video-transcode-cache', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: selectedBucket, key: currentVideoKey })
|
||||
});
|
||||
if (!res.ok) throw new Error('清空失败');
|
||||
clearPlayingTranscodeBtn.classList.add('hidden');
|
||||
fetchVideos(selectedBucket);
|
||||
} catch (err) {
|
||||
alert('清空转码缓存失败: ' + err.message);
|
||||
} finally {
|
||||
clearPlayingTranscodeBtn.disabled = false;
|
||||
clearPlayingTranscodeBtn.textContent = '清空转码缓存';
|
||||
}
|
||||
});
|
||||
}
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', login);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user