diff --git a/public/js/main.js b/public/js/main.js
index 747dca6..a3734dc 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -10,9 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
const loginPasswordInput = document.getElementById('login-password');
const loginBtn = document.getElementById('login-btn');
const loginError = document.getElementById('login-error');
- const decoderSelect = document.getElementById('decoder-select');
const encoderSelect = document.getElementById('encoder-select');
- const decoderLabel = document.querySelector('label[for="decoder-select"]');
const encoderLabel = document.querySelector('label[for="encoder-select"]');
const playerOverlay = document.getElementById('player-overlay');
const transcodingOverlay = document.getElementById('transcoding-overlay');
@@ -28,7 +26,10 @@ document.addEventListener('DOMContentLoaded', () => {
const controlPlayToggle = document.getElementById('control-play-toggle');
const controlMuteToggle = document.getElementById('control-mute-toggle');
const controlFullscreenToggle = document.getElementById('control-fullscreen-toggle');
+ const controlSubtitleToggle = document.getElementById('control-subtitle-toggle');
const controlSpeedToggle = document.getElementById('control-speed-toggle');
+ const subtitleControl = document.getElementById('subtitle-control');
+ const subtitleSelect = document.getElementById('subtitle-select');
const volumeSlider = document.getElementById('volume-slider');
const volumeValue = document.getElementById('volume-value');
const playbackStatus = document.getElementById('playback-status');
@@ -84,6 +85,8 @@ document.addEventListener('DOMContentLoaded', () => {
let controlsHideTimeout = null;
let controlsHovered = false;
let controlsPointerReleaseTimeout = null;
+ let managedSubtitleTracks = [];
+ let selectedSubtitleTrackId = 'off';
if (videoPlayer) {
videoPlayer.controls = false;
@@ -91,9 +94,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (playbackSpeed) {
videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1;
}
- if (decoderLabel) {
- decoderLabel.textContent = '视频解码器:';
- }
if (encoderLabel) {
encoderLabel.textContent = '视频编码器:';
}
@@ -142,10 +142,9 @@ document.addEventListener('DOMContentLoaded', () => {
};
const buildStreamUrl = (targetSeconds = null) => {
- const decoder = decoderSelect?.value || 'auto';
const encoder = encoderSelect?.value || 'h264_rkmpp';
const streamSessionId = createStreamSessionId();
- let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`;
+ let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=auto&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`;
if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
streamUrl += `&ss=${targetSeconds}`;
}
@@ -204,6 +203,119 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
+ const updateSubtitleControls = () => {
+ if (!controlSubtitleToggle || !subtitleSelect) return;
+ const selectedOption = subtitleSelect.selectedOptions?.[0];
+ controlSubtitleToggle.textContent = selectedOption ? selectedOption.textContent : 'CC Off';
+ };
+
+ const resetSubtitleTracks = () => {
+ managedSubtitleTracks.forEach((track) => track.element.remove());
+ managedSubtitleTracks = [];
+ selectedSubtitleTrackId = 'off';
+
+ if (subtitleSelect) {
+ subtitleSelect.innerHTML = '';
+ const offOption = document.createElement('option');
+ offOption.value = 'off';
+ offOption.textContent = 'Off';
+ subtitleSelect.appendChild(offOption);
+ subtitleSelect.value = 'off';
+ }
+
+ if (subtitleControl) {
+ subtitleControl.classList.add('hidden');
+ }
+
+ updateSubtitleControls();
+ };
+
+ const applySelectedSubtitleTrack = () => {
+ managedSubtitleTracks.forEach((track) => {
+ if (track.element.track) {
+ track.element.track.mode = track.id === selectedSubtitleTrackId ? 'showing' : 'disabled';
+ }
+ });
+ updateSubtitleControls();
+ };
+
+ const buildSubtitleUrl = (streamIndex, targetSeconds = 0) => {
+ let subtitleUrl = `/api/subtitles?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&streamIndex=${encodeURIComponent(streamIndex)}`;
+ if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
+ subtitleUrl += `&ss=${targetSeconds}`;
+ }
+ if (s3Username) subtitleUrl += `&username=${encodeURIComponent(s3Username)}`;
+ if (s3Password) subtitleUrl += `&password=${encodeURIComponent(s3Password)}`;
+ return subtitleUrl;
+ };
+
+ const loadSubtitleTracks = async (targetSeconds = 0) => {
+ if (!selectedBucket || !selectedKey || !subtitleSelect) {
+ resetSubtitleTracks();
+ return;
+ }
+
+ const previousSelection = selectedSubtitleTrackId;
+ resetSubtitleTracks();
+
+ try {
+ let subtitleListUrl = `/api/subtitles?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}`;
+ if (s3Username) subtitleListUrl += `&username=${encodeURIComponent(s3Username)}`;
+ if (s3Password) subtitleListUrl += `&password=${encodeURIComponent(s3Password)}`;
+
+ const res = await fetch(subtitleListUrl);
+ if (!res.ok) {
+ throw new Error('Failed to load subtitles');
+ }
+
+ const data = await res.json();
+ const tracks = Array.isArray(data.tracks) ? data.tracks : [];
+ if (tracks.length === 0) {
+ return;
+ }
+
+ tracks.forEach((track) => {
+ const option = document.createElement('option');
+ option.value = String(track.index);
+ option.textContent = track.label || `Subtitle ${track.index}`;
+ option.disabled = !track.supported;
+ subtitleSelect.appendChild(option);
+
+ if (!track.supported) {
+ return;
+ }
+
+ const trackElement = document.createElement('track');
+ trackElement.kind = 'subtitles';
+ trackElement.label = track.label || `Subtitle ${track.index}`;
+ if (track.language) {
+ trackElement.srclang = track.language;
+ }
+ trackElement.src = buildSubtitleUrl(track.index, targetSeconds);
+ trackElement.dataset.managedSubtitle = 'true';
+ videoPlayer.appendChild(trackElement);
+ managedSubtitleTracks.push({
+ id: String(track.index),
+ element: trackElement
+ });
+ });
+
+ if (subtitleControl) {
+ subtitleControl.classList.remove('hidden');
+ }
+
+ const nextSelection = managedSubtitleTracks.some((track) => track.id === previousSelection)
+ ? previousSelection
+ : 'off';
+ subtitleSelect.value = nextSelection;
+ selectedSubtitleTrackId = nextSelection;
+ applySelectedSubtitleTrack();
+ } catch (error) {
+ console.error('Subtitle load failed:', error);
+ resetSubtitleTracks();
+ }
+ };
+
const updateTranscodeProgressBar = (percent = 0) => {
if (!seekBarTranscode) return;
const safePercent = Math.min(Math.max(Math.round(percent || 0), 0), 100);
@@ -600,6 +712,10 @@ document.addEventListener('DOMContentLoaded', () => {
isStreamActive = true;
pendingSeekTimeout = null;
}, 8000);
+
+ loadSubtitleTracks(targetSeconds).catch((error) => {
+ console.error('Subtitle reload failed after seek:', error);
+ });
};
// --- End custom seek bar ---
@@ -614,7 +730,6 @@ document.addEventListener('DOMContentLoaded', () => {
topBanner.textContent = title;
topBanner.classList.remove('hidden');
document.title = title;
- populateSelect(decoderSelect, data.videoDecoders || [], data.defaultVideoDecoder || 'auto');
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
} catch (err) {
console.error('Config load failed:', err);
@@ -909,6 +1024,7 @@ document.addEventListener('DOMContentLoaded', () => {
playBtn.classList.add('hidden');
}
resetPhases();
+ resetSubtitleTracks();
selectedKey = key;
currentVideoKey = key;
@@ -956,6 +1072,9 @@ document.addEventListener('DOMContentLoaded', () => {
updateVolumeControls();
updateFullscreenControls();
schedulePlaybackChromeHide();
+ loadSubtitleTracks(seekOffset).catch((error) => {
+ console.error('Subtitle load failed:', error);
+ });
}, { once: true });
} catch (err) {
console.error(err);
@@ -984,6 +1103,7 @@ document.addEventListener('DOMContentLoaded', () => {
videoPlayer.load();
hideSeekBar();
hideCustomControls();
+ resetSubtitleTracks();
setPlaybackStatus('Paused', 'paused');
updatePlayControls();
if (transcodeBtn) {
@@ -1032,6 +1152,9 @@ document.addEventListener('DOMContentLoaded', () => {
updateVolumeControls();
updateFullscreenControls();
schedulePlaybackChromeHide();
+ loadSubtitleTracks(seekOffset).catch((error) => {
+ console.error('Subtitle load failed:', error);
+ });
}, { once: true });
};
@@ -1082,6 +1205,15 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
+ if (subtitleSelect) {
+ subtitleSelect.addEventListener('change', (event) => {
+ selectedSubtitleTrackId = event.target.value || 'off';
+ applySelectedSubtitleTrack();
+ revealPlaybackChrome();
+ schedulePlaybackChromeHide();
+ });
+ }
+
if (controlFullscreenToggle) {
controlFullscreenToggle.addEventListener('click', async () => {
try {
@@ -1133,6 +1265,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateVolumeControls();
updateFullscreenControls();
updateSpeedControls();
+ resetSubtitleTracks();
updateTranscodeProgressBar(0);
// Bind events
diff --git a/server.js b/server.js
index 0162c67..4859897 100644
--- a/server.js
+++ b/server.js
@@ -72,36 +72,33 @@ const transcodeProcesses = new Map();
const wsSubscriptions = new Map();
const AVAILABLE_VIDEO_ENCODERS = [
- { value: 'libx264', label: 'libx264 (Software H.264)' },
- { value: 'libx265', label: 'libx265 (Software H.265)' },
- { value: 'h264_nvenc', label: 'h264_nvenc (NVIDIA H.264)' },
- { value: 'hevc_nvenc', label: 'hevc_nvenc (NVIDIA HEVC)' },
- { value: 'h264_qsv', label: 'h264_qsv (Intel QSV H.264)' },
- { value: 'hevc_qsv', label: 'hevc_qsv (Intel QSV HEVC)' },
- { value: 'h264_vaapi', label: 'h264_vaapi (VAAPI H.264)' },
- { value: 'hevc_vaapi', label: 'hevc_vaapi (VAAPI HEVC)' },
- { value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
- { value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
- { value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' }
-];
-
-const AVAILABLE_VIDEO_DECODERS = [
- { value: 'auto', label: 'Auto Select Decoder' },
- { value: 'av1_rkmpp', label: 'av1_rkmpp (RKMPP AV1)' },
- { value: 'h263_rkmpp', label: 'h263_rkmpp (RKMPP H.263)' },
{ value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
{ value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
{ value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' },
- { value: 'mpeg1_rkmpp', label: 'mpeg1_rkmpp (RKMPP MPEG-1)' },
- { value: 'mpeg2_rkmpp', label: 'mpeg2_rkmpp (RKMPP MPEG-2)' },
- { value: 'mpeg4_rkmpp', label: 'mpeg4_rkmpp (RKMPP MPEG-4)' },
- { value: 'vp8_rkmpp', label: 'vp8_rkmpp (RKMPP VP8)' },
- { value: 'vp9_rkmpp', label: 'vp9_rkmpp (RKMPP VP9)' }
+ { value: 'libx264', label: 'libx264 (Software / NEON H.264)' },
+ { value: 'libx265', label: 'libx265 (Software / NEON H.265)' }
];
+const AVAILABLE_VIDEO_DECODERS = [
+ { value: 'auto', label: 'Auto Select Decoder' }
+];
+
+const SUPPORTED_TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'text', 'webvtt']);
+
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+const getCachePathParts = (bucket, key) => {
+ const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
+ const progressKey = safeKeySegments.join('/');
+ const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
+ const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
+ return {
+ progressKey,
+ safeKeySegments,
+ tmpInputPath
+ };
+};
const addWsClient = (progressKey, ws) => {
if (!wsSubscriptions.has(progressKey)) {
@@ -220,6 +217,73 @@ const shouldRetryWithSoftware = (message) => {
return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|mpp_create|rkmpp/i.test(message);
};
+const ensureSourceCached = async ({ s3Client, bucket, key, tmpInputPath, onProgress }) => {
+ if (fs.existsSync(tmpInputPath)) {
+ const stats = fs.statSync(tmpInputPath);
+ return {
+ totalBytes: stats.size,
+ downloadedBytes: stats.size,
+ cacheExists: true
+ };
+ }
+
+ const command = new GetObjectCommand({ Bucket: bucket, Key: key });
+ const response = await s3Client.send(command);
+ const s3Stream = response.Body;
+ const totalBytes = response.ContentLength || 0;
+ let downloadedBytes = 0;
+
+ if (typeof onProgress === 'function') {
+ onProgress({ totalBytes, downloadedBytes, cacheExists: false });
+ }
+
+ await new Promise((resolve, reject) => {
+ const writeStream = fs.createWriteStream(tmpInputPath);
+ s3Stream.on('data', (chunk) => {
+ downloadedBytes += chunk.length;
+ if (typeof onProgress === 'function') {
+ onProgress({ totalBytes, downloadedBytes, cacheExists: false });
+ }
+ });
+ s3Stream.on('error', reject);
+ writeStream.on('error', reject);
+ writeStream.on('finish', resolve);
+ s3Stream.pipe(writeStream);
+ });
+
+ return {
+ totalBytes,
+ downloadedBytes,
+ cacheExists: false
+ };
+};
+
+const getSubtitleTracks = (metadata) => {
+ return (metadata?.streams || [])
+ .filter((stream) => stream.codec_type === 'subtitle')
+ .map((stream, orderIndex) => {
+ const codec = (stream.codec_name || '').toLowerCase();
+ const language = stream.tags?.language || '';
+ const title = stream.tags?.title || '';
+ const supported = SUPPORTED_TEXT_SUBTITLE_CODECS.has(codec);
+ const labelParts = [
+ language ? language.toUpperCase() : `Subtitle ${orderIndex + 1}`,
+ title || null,
+ codec ? codec : null,
+ supported ? null : 'Unsupported'
+ ].filter(Boolean);
+
+ return {
+ index: stream.index,
+ codec,
+ language,
+ title,
+ supported,
+ label: labelParts.join(' / ')
+ };
+ });
+};
+
const probeFile = (filePath) => {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
@@ -410,6 +474,75 @@ app.get('/api/config', (req, res) => {
});
});
+app.get('/api/subtitles', async (req, res) => {
+ const bucket = req.query.bucket;
+ const key = req.query.key;
+ const requestedStreamIndex = typeof req.query.streamIndex === 'string' ? req.query.streamIndex.trim() : '';
+ const startSeconds = Math.max(0, parseFloat(req.query.ss) || 0);
+
+ if (!bucket) {
+ return res.status(400).json({ error: 'Bucket name is required' });
+ }
+ if (!key) {
+ return res.status(400).json({ error: 'Video key is required' });
+ }
+
+ const { tmpInputPath } = getCachePathParts(bucket, key);
+ const auth = extractS3Credentials(req);
+ const s3Client = createS3Client(auth);
+
+ try {
+ await ensureSourceCached({ s3Client, bucket, key, tmpInputPath });
+
+ const metadata = await probeFile(tmpInputPath);
+ const tracks = getSubtitleTracks(metadata);
+
+ if (!requestedStreamIndex) {
+ return res.json({ tracks });
+ }
+
+ const numericStreamIndex = parseInt(requestedStreamIndex, 10);
+ const matchedTrack = tracks.find((track) => track.index === numericStreamIndex);
+ if (!matchedTrack) {
+ return res.status(404).json({ error: 'Subtitle track not found' });
+ }
+ if (!matchedTrack.supported) {
+ return res.status(415).json({ error: 'Subtitle codec is not supported for WebVTT output', codec: matchedTrack.codec });
+ }
+
+ res.setHeader('Content-Type', 'text/vtt; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache');
+
+ const subtitleCommand = ffmpeg()
+ .input(tmpInputPath)
+ .outputOptions(['-map', `0:${numericStreamIndex}`, '-reset_timestamps', '1'])
+ .format('webvtt')
+ .noAudio()
+ .noVideo();
+
+ if (startSeconds > 0) {
+ if (typeof subtitleCommand.seekInput === 'function') {
+ subtitleCommand.seekInput(startSeconds);
+ } else {
+ subtitleCommand.inputOptions(['-ss', startSeconds.toString()]);
+ }
+ }
+
+ subtitleCommand
+ .on('error', (error) => {
+ if (!res.headersSent) {
+ res.status(500).json({ error: 'Failed to extract subtitles', detail: error.message });
+ } else {
+ res.destroy(error);
+ }
+ })
+ .pipe(res, { end: true });
+ } catch (error) {
+ console.error('Error serving subtitles:', error);
+ res.status(500).json({ error: 'Failed to load subtitles', detail: error.message });
+ }
+});
+
app.post('/api/clear-download-cache', (req, res) => {
try {
clearDownloadCache();
@@ -466,11 +599,7 @@ app.get('/api/stream', async (req, res) => {
const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
- const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
- const progressKey = safeKeySegments.join('/');
- const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
- const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
- const cacheExists = fs.existsSync(tmpInputPath);
+ const { progressKey, tmpInputPath } = getCachePathParts(bucket, key);
const auth = extractS3Credentials(req);
const s3Client = createS3Client(auth);
@@ -492,13 +621,9 @@ app.get('/api/stream', async (req, res) => {
let totalBytes = 0;
let downloadedBytes = 0;
+ const cacheExists = fs.existsSync(tmpInputPath);
if (!cacheExists) {
- const command = new GetObjectCommand({ Bucket: bucket, Key: key });
- const response = await s3Client.send(command);
- const s3Stream = response.Body;
- totalBytes = response.ContentLength || 0;
-
progressMap[progressKey] = {
status: 'downloading',
percent: 0,
@@ -510,10 +635,14 @@ app.get('/api/stream', async (req, res) => {
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
- await new Promise((resolve, reject) => {
- const writeStream = fs.createWriteStream(tmpInputPath);
- s3Stream.on('data', (chunk) => {
- downloadedBytes += chunk.length;
+ await ensureSourceCached({
+ s3Client,
+ bucket,
+ key,
+ tmpInputPath,
+ onProgress: ({ totalBytes: nextTotalBytes, downloadedBytes: nextDownloadedBytes }) => {
+ totalBytes = nextTotalBytes;
+ downloadedBytes = nextDownloadedBytes;
const percent = totalBytes ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0;
const downloadState = {
status: 'downloading',
@@ -526,11 +655,7 @@ app.get('/api/stream', async (req, res) => {
};
progressMap[progressKey] = downloadState;
broadcastWs(progressKey, { type: 'progress', key, progress: downloadState });
- });
- s3Stream.on('error', reject);
- writeStream.on('error', reject);
- writeStream.on('finish', resolve);
- s3Stream.pipe(writeStream);
+ }
});
progressMap[progressKey] = {