diff --git a/public/css/style.css b/public/css/style.css
index 5c7400b..50311aa 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -113,6 +113,12 @@ header p {
min-width: 0;
}
+.video-list-section,
+.player-section,
+.folder-header {
+ min-width: 0;
+}
+
.glass-panel {
background: var(--panel-bg);
backdrop-filter: blur(16px);
@@ -137,6 +143,32 @@ header p {
font-weight: 600;
}
+.codec-panel {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.codec-panel label {
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+}
+
+.codec-panel select {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-primary);
+ border: 1px solid var(--panel-border);
+ border-radius: 10px;
+ padding: 0.5rem 0.75rem;
+ min-width: 110px;
+ outline: none;
+}
+
+.codec-panel select:focus {
+ border-color: var(--accent);
+}
+
.icon-btn {
background: none;
border: none;
@@ -159,7 +191,6 @@ header p {
gap: 0.5rem;
max-height: 500px;
overflow-y: auto;
- overflow-x: auto;
min-width: 0;
}
@@ -212,15 +243,16 @@ header p {
flex: 1;
overflow: hidden;
min-width: 0;
+ max-width: 100%;
}
.video-title {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
- overflow-x: auto;
- overflow-y: hidden;
- text-overflow: clip;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
padding-bottom: 0.2rem;
}
diff --git a/public/index.html b/public/index.html
index 5d45b53..0273a9f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -32,6 +32,13 @@
+
Fetching S3 Objects...
diff --git a/public/js/main.js b/public/js/main.js
index 611d60c..df7836f 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => {
const videoListEl = document.getElementById('video-list');
const loadingSpinner = document.getElementById('loading-spinner');
const refreshBtn = document.getElementById('refresh-btn');
+ const codecSelect = document.getElementById('codec-select');
const playerOverlay = document.getElementById('player-overlay');
const transcodingOverlay = document.getElementById('transcoding-overlay');
const videoPlayer = document.getElementById('video-player');
@@ -30,46 +31,54 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- // Build a tree structure from S3 keys
+ // Build a tree structure from S3 keys, preserving original object storage directories
const tree = {};
data.videos.forEach(key => {
const parts = key.split('/');
let current = tree;
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
+ parts.forEach((part, index) => {
if (!current[part]) {
- current[part] = (i === parts.length - 1) ? key : {};
+ current[part] = {};
}
- if (i < parts.length - 1) {
- current = current[part];
+
+ if (index === parts.length - 1) {
+ current[part].__file = key;
}
- }
+
+ current = current[part];
+ });
});
+ const createFileItem = (name, key) => {
+ const li = document.createElement('li');
+ li.className = 'video-item file-item';
+ const ext = name.split('.').pop().toUpperCase();
+ li.innerHTML = `
+
+
+
${name}
+
Video / ${ext}
+
+ `;
+ li.addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectVideo(key, li);
+ });
+ return li;
+ };
+
// Recursive function to render the tree
const renderTree = (node, container) => {
for (const [name, value] of Object.entries(node)) {
- if (typeof value === 'string') {
- // It's a file
- const li = document.createElement('li');
- li.className = 'video-item file-item';
- const ext = name.split('.').pop().toUpperCase();
- li.innerHTML = `
-
-
-
${name}
-
Video / ${ext}
-
- `;
- li.addEventListener('click', (e) => {
- e.stopPropagation();
- selectVideo(value, li);
- });
- container.appendChild(li);
- } else {
- // It's a folder
+ if (name === '__file') continue;
+
+ const childKeys = Object.keys(value).filter(key => key !== '__file');
+ const hasChildren = childKeys.length > 0;
+ const isFile = typeof value.__file === 'string';
+
+ if (hasChildren) {
const li = document.createElement('li');
li.className = 'folder-item';
@@ -95,9 +104,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
li.appendChild(folderHeader);
+ if (isFile) {
+ subListContainer.appendChild(createFileItem(name, value.__file));
+ }
renderTree(value, subListContainer);
li.appendChild(subListContainer);
container.appendChild(li);
+ } else if (isFile) {
+ container.appendChild(createFileItem(name, value.__file));
}
}
};
@@ -127,10 +141,11 @@ document.addEventListener('DOMContentLoaded', () => {
transcodingOverlay.classList.remove('hidden');
try {
+ const codec = codecSelect?.value || 'h264';
const res = await fetch('/api/transcode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key })
+ body: JSON.stringify({ key, codec })
});
const data = await res.json();
diff --git a/server.js b/server.js
index 3b90741..6656a1e 100644
--- a/server.js
+++ b/server.js
@@ -64,17 +64,20 @@ app.get('/api/videos', async (req, res) => {
// Endpoint to transcode S3 video streaming to HLS
app.post('/api/transcode', async (req, res) => {
- const { key } = req.body;
+ const { key, codec } = req.body;
if (!key) {
return res.status(400).json({ error: 'Video key is required' });
}
+ const safeCodec = codec === 'h265' ? 'h265' : 'h264';
+ const videoCodec = safeCodec === 'h265' ? 'libx265' : 'libx264';
+
try {
- const safeKeyName = key.replace(/[^a-zA-Z0-9_\-]/g, '');
- const outputDir = path.join(__dirname, 'public', 'hls', safeKeyName);
+ const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
+ const outputDir = path.join(__dirname, 'public', 'hls', ...safeKeySegments);
const m3u8Path = path.join(outputDir, 'index.m3u8');
- const hlsUrl = `/hls/${safeKeyName}/index.m3u8`;
+ const hlsUrl = `/hls/${safeKeySegments.join('/')}/index.m3u8`;
// If it already exists, just return the URL
if (fs.existsSync(m3u8Path)) {
@@ -94,15 +97,18 @@ app.post('/api/transcode', async (req, res) => {
const s3Stream = response.Body;
// Triggers fluent-ffmpeg to transcode to HLS
- console.log(`Starting transcoding for ${key}`);
+ console.log(`Starting transcoding for ${key} with codec ${videoCodec}`);
ffmpeg(s3Stream)
- // Use hardware acceleration if available (optional for boilerplate)
+ .videoCodec(videoCodec)
+ .audioCodec('aac')
.outputOptions([
- '-codec:v copy', // Use copy if it's already h264, else change to libx264
- '-codec:a aac',
- '-hls_time 10', // 10 second segments
- '-hls_list_size 0', // keep all segments in playlist
+ '-preset fast',
+ '-crf 23',
+ '-hls_time 6',
+ '-hls_list_size 0',
+ '-hls_allow_cache 1',
+ '-hls_flags independent_segments',
'-f hls'
])
.output(m3u8Path)
@@ -128,12 +134,12 @@ app.get('/api/status', (req, res) => {
const { key } = req.query;
if (!key) return res.status(400).json({ error: 'Key is required' });
- const safeKeyName = key.replace(/[^a-zA-Z0-9_\-]/g, '');
- const m3u8Path = path.join(__dirname, 'public', 'hls', safeKeyName, 'index.m3u8');
+ const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
+ const m3u8Path = path.join(__dirname, 'public', 'hls', ...safeKeySegments, 'index.m3u8');
// Check if the playlist file exists
if (fs.existsSync(m3u8Path)) {
- res.json({ ready: true, hlsUrl: `/hls/${safeKeyName}/index.m3u8` });
+ res.json({ ready: true, hlsUrl: `/hls/${safeKeySegments.join('/')}/index.m3u8` });
} else {
res.json({ ready: false });
}