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'); const loginUsernameInput = document.getElementById('login-username'); const loginPasswordInput = document.getElementById('login-password'); const loginBtn = document.getElementById('login-btn'); const loginError = document.getElementById('login-error'); const codecSelect = document.getElementById('codec-select'); const encoderSelect = document.getElementById('encoder-select'); const playerOverlay = document.getElementById('player-overlay'); const transcodingOverlay = document.getElementById('transcoding-overlay'); const videoPlayer = document.getElementById('video-player'); const nowPlaying = document.getElementById('now-playing'); const currentVideoTitle = document.getElementById('current-video-title'); const transcodeBtn = document.getElementById('transcode-btn'); const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); const playBtn = document.getElementById('play-btn'); const progressInfo = document.getElementById('progress-info'); const progressText = document.getElementById('progress-text'); const progressFill = document.getElementById('progress-fill'); const topBanner = document.getElementById('top-banner'); let currentPollInterval = null; let selectedBucket = null; let selectedKey = null; let ws = null; let wsConnected = false; let subscribedKey = null; let currentVideoKey = null; let s3AuthHeaders = {}; const sendWsMessage = (message) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } }; const subscribeToKey = (key) => { subscribedKey = key; if (wsConnected) { sendWsMessage({ type: 'subscribe', key }); } }; const handleWsMessage = (event) => { try { const message = JSON.parse(event.data); if (message.type === 'progress' && message.key === currentVideoKey) { setProgress(message.progress); } if (message.type === 'ready' && message.key === currentVideoKey) { setProgress({ status: 'Ready to play', percent: 100, details: 'Ready to play' }); playMp4Stream(message.mp4Url); } } catch (error) { console.error('WebSocket message parse error:', error); } }; const loadConfig = async () => { if (!topBanner) return; try { const res = await fetch('/api/config'); if (!res.ok) throw new Error('Failed to load config'); const data = await res.json(); const title = data.title || 'S3 Media Transcoder'; topBanner.textContent = title; topBanner.classList.remove('hidden'); document.title = title; } catch (err) { console.error('Config load failed:', err); } }; const connectWebSocket = () => { const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${protocol}://${window.location.host}`); ws.addEventListener('open', () => { wsConnected = true; if (subscribedKey) { sendWsMessage({ type: 'subscribe', key: subscribedKey }); } }); ws.addEventListener('message', handleWsMessage); ws.addEventListener('close', () => { wsConnected = false; setTimeout(connectWebSocket, 2000); }); ws.addEventListener('error', (error) => { console.error('WebSocket error:', error); wsConnected = false; }); }; const setProgress = (progress) => { if (!progressInfo || !progressText || !progressFill) return; const percent = Math.min(Math.max(Math.round(progress?.percent || 0), 0), 100); progressInfo.classList.remove('hidden'); progressText.textContent = `${progress?.details || progress?.status || 'Transcoding...'} ${percent ? `${percent}%` : ''}`.trim(); progressFill.style.width = `${percent}%`; }; const resetProgress = () => { if (!progressInfo || !progressText || !progressFill) return; progressInfo.classList.add('hidden'); progressText.textContent = ''; progressFill.style.width = '0%'; if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); stopTranscodeBtn.disabled = false; stopTranscodeBtn.textContent = 'Stop Transcode'; } }; const setAuthHeaders = (username, password) => { s3AuthHeaders = {}; if (username) s3AuthHeaders['X-S3-Username'] = username; if (password) s3AuthHeaders['X-S3-Password'] = password; }; const showLogin = () => { if (loginScreen) loginScreen.classList.remove('hidden'); if (appContainer) appContainer.classList.add('hidden'); }; const showApp = () => { if (loginScreen) loginScreen.classList.add('hidden'); if (appContainer) appContainer.classList.remove('hidden'); }; const renderBuckets = (buckets) => { if (!bucketSelect) return; bucketSelect.innerHTML = ''; buckets.forEach(bucket => { const option = document.createElement('option'); option.value = bucket.Name; option.textContent = bucket.Name; bucketSelect.appendChild(option); }); }; const showLoginError = (message) => { if (!loginError) return; loginError.textContent = message; loginError.classList.remove('hidden'); }; const clearLoginError = () => { if (!loginError) return; loginError.textContent = ''; loginError.classList.add('hidden'); }; const login = async () => { if (!loginUsernameInput || !loginPasswordInput) return; const username = loginUsernameInput.value.trim(); const password = loginPasswordInput.value; if (!username || !password) { showLoginError('Please enter both access key and secret key.'); return; } loginBtn.disabled = true; loginBtn.textContent = 'Logging in...'; clearLoginError(); try { setAuthHeaders(username, password); const res = await fetch('/api/buckets', { headers: s3AuthHeaders }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || 'Login failed'); } const data = await res.json(); if (!Array.isArray(data.buckets) || data.buckets.length === 0) { throw new Error('No buckets available for this account'); } renderBuckets(data.buckets); showApp(); selectedBucket = data.buckets[0].Name; if (bucketSelect) bucketSelect.value = selectedBucket; loadConfig(); connectWebSocket(); await fetchVideos(selectedBucket); } catch (err) { console.error('Login error:', err); showLoginError(err.message); setAuthHeaders('', ''); } finally { if (loginBtn) { loginBtn.disabled = false; loginBtn.textContent = 'Login'; } } }; 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', () => { startTranscode(); }); } if (playBtn) { playBtn.addEventListener('click', () => { videoPlayer.play().catch(e => console.log('Play blocked:', e)); playBtn.classList.add('hidden'); }); } // Fetch list of videos from the backend const fetchVideos = async (bucket) => { if (!bucket) { return; } videoListEl.classList.add('hidden'); loadingSpinner.classList.remove('hidden'); videoListEl.innerHTML = ''; try { const res = await fetch(`/api/videos?bucket=${encodeURIComponent(bucket)}`, { headers: s3AuthHeaders }); if (!res.ok) throw new Error('Failed to fetch videos. Check S3 Config.'); const data = await res.json(); loadingSpinner.classList.add('hidden'); videoListEl.classList.remove('hidden'); if (data.videos.length === 0) { videoListEl.innerHTML = '

No videos found.

'; return; } // 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; parts.forEach((part, index) => { if (!current[part]) { 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 (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'; const folderHeader = document.createElement('div'); folderHeader.className = 'folder-header'; folderHeader.innerHTML = `
${name}
`; const subListContainer = document.createElement('ul'); subListContainer.className = 'sub-list hidden'; folderHeader.addEventListener('click', (e) => { e.stopPropagation(); li.classList.toggle('open'); subListContainer.classList.toggle('hidden'); }); 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)); } } }; renderTree(tree, videoListEl); } catch (err) { console.error(err); loadingSpinner.innerHTML = `

Error: ${err.message}

`; } }; // Handle video selection and trigger transcode const selectVideo = (key, listItemNode) => { // Update UI document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active')); listItemNode.classList.add('active'); // Reset player UI stopPolling(); playerOverlay.classList.add('hidden'); videoPlayer.classList.add('hidden'); videoPlayer.pause(); if (transcodeBtn) { transcodeBtn.disabled = false; transcodeBtn.textContent = 'Start Transcode'; transcodeBtn.classList.remove('hidden'); } if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); stopTranscodeBtn.disabled = false; stopTranscodeBtn.textContent = 'Stop Transcode'; } if (playBtn) { playBtn.classList.add('hidden'); } resetProgress(); selectedKey = key; currentVideoKey = key; subscribeToKey(key); nowPlaying.classList.remove('hidden'); currentVideoTitle.textContent = key.split('/').pop(); }; const startTranscode = async () => { if (!selectedKey) return; if (transcodeBtn) { transcodeBtn.disabled = true; transcodeBtn.textContent = 'Starting...'; } if (stopTranscodeBtn) { stopTranscodeBtn.classList.remove('hidden'); stopTranscodeBtn.disabled = false; stopTranscodeBtn.textContent = 'Stop Transcode'; } stopPolling(); transcodingOverlay.classList.remove('hidden'); setProgress({ status: 'Starting stream...', percent: 0, details: 'Starting stream...' }); try { const codec = codecSelect?.value || 'h264'; const encoder = encoderSelect?.value || 'software'; if (!selectedBucket) throw new Error('No bucket selected'); const streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&codec=${encodeURIComponent(codec)}&encoder=${encodeURIComponent(encoder)}`; videoPlayer.src = streamUrl; videoPlayer.load(); videoPlayer.addEventListener('loadedmetadata', () => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); if (playBtn) { playBtn.disabled = false; playBtn.textContent = 'Play'; playBtn.classList.remove('hidden'); } }, { once: true }); } catch (err) { console.error(err); transcodingOverlay.innerHTML = `

Transcode Failed: ${err.message}

`; } }; const stopTranscode = async () => { if (!currentVideoKey || !stopTranscodeBtn) return; stopTranscodeBtn.disabled = true; stopTranscodeBtn.textContent = 'Stopping...'; try { const res = await fetch('/api/stop-transcode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: currentVideoKey }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to stop transcode'); setProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' }); if (transcodeBtn) { transcodeBtn.disabled = false; transcodeBtn.textContent = 'Start Transcode'; transcodeBtn.classList.remove('hidden'); } stopTranscodeBtn.classList.add('hidden'); } catch (err) { console.error('Stop transcode failed:', err); alert(`Stop transcode failed: ${err.message}`); } finally { if (stopTranscodeBtn) { stopTranscodeBtn.disabled = false; stopTranscodeBtn.textContent = 'Stop Transcode'; } } }; const stopPolling = () => { if (currentPollInterval) { clearInterval(currentPollInterval); currentPollInterval = null; } }; // Initialize MP4 Player const playMp4Stream = (url) => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); playBtn.classList.add('hidden'); if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); } resetProgress(); videoPlayer.src = url; videoPlayer.load(); videoPlayer.addEventListener('loadedmetadata', () => { if (playBtn) { playBtn.disabled = false; playBtn.textContent = 'Play'; playBtn.classList.remove('hidden'); } }, { once: true }); }; // Bind events refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket)); if (clearDownloadCacheBtn) { clearDownloadCacheBtn.addEventListener('click', clearDownloadCache); } if (stopTranscodeBtn) { stopTranscodeBtn.addEventListener('click', stopTranscode); } if (loginBtn) { loginBtn.addEventListener('click', login); } if (bucketSelect) { bucketSelect.addEventListener('change', async (event) => { selectedBucket = event.target.value; await fetchVideos(selectedBucket); }); } // Initial state: require login before loading data showLogin(); });