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