document.addEventListener('DOMContentLoaded', () => { const videoListEl = document.getElementById('video-list'); const loadingSpinner = document.getElementById('loading-spinner'); const refreshBtn = document.getElementById('refresh-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 encoderSelect = document.getElementById('encoder-select'); const playerOverlay = document.getElementById('player-overlay'); const transcodingOverlay = document.getElementById('transcoding-overlay'); const videoPlayer = document.getElementById('video-player'); const playerWrapper = document.getElementById('player-wrapper'); 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 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 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'); const customControls = document.getElementById('custom-controls'); const controlPlayToggle = document.getElementById('control-play-toggle'); const controlMuteToggle = document.getElementById('control-mute-toggle'); const controlFullscreenToggle = document.getElementById('control-fullscreen-toggle'); const controlSpeedToggle = document.getElementById('control-speed-toggle'); const volumeSlider = document.getElementById('volume-slider'); const volumeValue = document.getElementById('volume-value'); const playbackStatus = document.getElementById('playback-status'); const playbackStatusText = document.getElementById('playback-status-text'); const playbackSpeed = document.getElementById('playback-speed'); // Download phase elements const downloadPhase = document.getElementById('download-phase'); const downloadSizeText = document.getElementById('download-size-text'); const downloadProgressText = document.getElementById('download-progress-text'); const downloadProgressFill = document.getElementById('download-progress-fill'); // Transcode phase elements const transcodePhase = document.getElementById('transcode-phase'); const transcodeDetailText = document.getElementById('transcode-detail-text'); const transcodeProgressText = document.getElementById('transcode-progress-text'); const transcodeProgressFill = document.getElementById('transcode-progress-fill'); const transcodeStats = document.getElementById('transcode-stats'); const statFps = document.getElementById('stat-fps'); const statBitrate = document.getElementById('stat-bitrate'); const statTime = document.getElementById('stat-time'); // Custom seek bar elements const customSeekContainer = document.getElementById('custom-seek-container'); const seekBar = document.getElementById('seek-bar'); const seekBarTranscode = document.getElementById('seek-bar-transcode'); const seekBarProgress = document.getElementById('seek-bar-progress'); const seekBarHandle = document.getElementById('seek-bar-handle'); const seekCurrentTime = document.getElementById('seek-current-time'); const seekTotalTime = document.getElementById('seek-total-time'); const seekingOverlay = document.getElementById('seeking-overlay'); let hlsInstance = null; let currentPollInterval = null; let selectedBucket = null; let selectedKey = null; let ws = null; let wsConnected = false; let subscribedKey = null; let currentVideoKey = null; let s3Username = ''; let s3Password = ''; let s3AuthHeaders = {}; // Seek state let videoDuration = 0; let seekOffset = 0; let isDraggingSeek = false; let isStreamActive = false; let pendingSeekTimeout = null; let internalSeeking = false; let lastAbsolutePlaybackTime = 0; let lastVolumeBeforeMute = 1; let controlsHideTimeout = null; let controlsHovered = false; let controlsPointerReleaseTimeout = null; if (videoPlayer) { videoPlayer.controls = true; } if (playbackSpeed) { videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1; } const formatBytes = (bytes) => { if (!bytes || bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const k = 1024; const i = Math.floor(Math.log(bytes) / Math.log(k)); return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i]; }; const formatTime = (seconds) => { if (!seconds || !isFinite(seconds) || seconds < 0) return '00:00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; }; const sendWsMessage = (message) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } }; const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const populateSelect = (selectEl, options, fallbackValue) => { if (!selectEl) return; selectEl.innerHTML = ''; (options || []).forEach((item) => { const option = document.createElement('option'); option.value = item.value; option.textContent = item.label || item.value; selectEl.appendChild(option); }); const preferredValue = options?.some((item) => item.value === fallbackValue) ? fallbackValue : options?.[0]?.value; if (preferredValue) { selectEl.value = preferredValue; } }; const buildHlsPlaylistUrl = () => { 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)}`; } else { if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`; if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`; } return streamUrl; }; const updatePlayControls = () => { if (controlPlayToggle) { controlPlayToggle.textContent = videoPlayer.paused ? '>' : '||'; controlPlayToggle.setAttribute('aria-label', videoPlayer.paused ? 'Play' : 'Pause'); } if (playbackStatus) { playbackStatus.classList.remove('playing', 'paused', 'seeking'); playbackStatus.classList.add(videoPlayer.paused ? 'paused' : 'playing'); } if (playbackStatusText) { playbackStatusText.textContent = videoPlayer.paused ? 'Paused' : 'Playing'; } if (playBtn) { if (videoPlayer.paused && isStreamActive) { playBtn.disabled = false; playBtn.textContent = 'Play'; playBtn.classList.remove('hidden'); } else { playBtn.classList.add('hidden'); } } }; const updateVolumeControls = () => { const effectiveVolume = videoPlayer.muted ? 0 : videoPlayer.volume; if (volumeSlider) { volumeSlider.value = String(effectiveVolume); } if (volumeValue) { volumeValue.textContent = `${Math.round(effectiveVolume * 100)}%`; } if (controlMuteToggle) { controlMuteToggle.textContent = effectiveVolume === 0 ? 'x' : 'o'; controlMuteToggle.setAttribute('aria-label', effectiveVolume === 0 ? 'Unmute' : 'Mute'); } }; const updateFullscreenControls = () => { if (controlFullscreenToggle) { controlFullscreenToggle.textContent = '[]'; controlFullscreenToggle.setAttribute('aria-label', document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen'); } }; const updateSpeedControls = () => { if (controlSpeedToggle && playbackSpeed) { controlSpeedToggle.textContent = playbackSpeed.value === '1' ? '1x' : playbackSpeed.value; } }; const updateTranscodeProgressBar = (percent = 0) => { if (!seekBarTranscode) return; const safePercent = Math.min(Math.max(Math.round(percent || 0), 0), 100); seekBarTranscode.style.width = `${safePercent}%`; }; const showCustomControls = () => { if (customControls) { customControls.classList.remove('hidden'); customControls.classList.remove('controls-faded'); } if (customSeekContainer) { customSeekContainer.classList.remove('controls-faded'); } }; const hideCustomControls = () => { if (customControls) { customControls.classList.add('hidden'); } if (customSeekContainer) { customSeekContainer.classList.remove('controls-faded'); } }; const revealPlaybackChrome = () => { if (!isStreamActive) return; if (customControls) customControls.classList.remove('controls-faded'); if (customSeekContainer) customSeekContainer.classList.remove('controls-faded'); }; const fadePlaybackChrome = () => { if (!isStreamActive || videoPlayer.paused || controlsHovered || isDraggingSeek) return; if (customControls) customControls.classList.add('controls-faded'); if (customSeekContainer) customSeekContainer.classList.add('controls-faded'); }; const schedulePlaybackChromeHide = () => { if (controlsHideTimeout) { clearTimeout(controlsHideTimeout); } revealPlaybackChrome(); if (!isStreamActive || videoPlayer.paused || controlsHovered) { return; } controlsHideTimeout = setTimeout(() => { fadePlaybackChrome(); controlsHideTimeout = null; }, 1800); }; const setPlaybackStatus = (statusText, statusClass) => { if (playbackStatus) { playbackStatus.classList.remove('playing', 'paused', 'seeking'); if (statusClass) playbackStatus.classList.add(statusClass); } if (playbackStatusText) { playbackStatusText.textContent = statusText; } }; const subscribeToKey = (key) => { let subscriptionKey = key; const subIndex = subtitleSelector?.value; if (subIndex && subIndex !== '-1') { subscriptionKey = `${key}-sub${subIndex}`; } subscribedKey = subscriptionKey; if (wsConnected) { sendWsMessage({ type: 'subscribe', key: subscriptionKey }); } }; const handleWsMessage = (event) => { try { const message = JSON.parse(event.data); if (message.key !== subscribedKey) return; if (message.type === 'duration' && message.duration) { videoDuration = message.duration; if (seekTotalTime) seekTotalTime.textContent = formatTime(videoDuration); showSeekBar(); updateSeekBarPosition(seekOffset + (videoPlayer.currentTime || 0)); } if (message.type === 'progress') { handleProgress(message.progress); } if (message.type === 'ready') { handleProgress({ status: 'finished', percent: 100, details: 'Ready to play' }); playMp4Stream(message.mp4Url); } } catch (error) { console.error('WebSocket message parse error:', error); } }; const handleProgress = (progress) => { if (!progress) return; const status = progress.status; if (status === 'downloading') { showDownloadPhase(); const percent = Math.min(Math.max(Math.round(progress.percent || 0), 0), 100); updateTranscodeProgressBar(percent); const downloaded = formatBytes(progress.downloadedBytes || 0); const total = formatBytes(progress.totalBytes || 0); downloadSizeText.textContent = `${downloaded} / ${total}`; downloadProgressText.textContent = `${percent}%`; downloadProgressFill.style.width = `${percent}%`; } else if (status === 'downloaded') { showDownloadPhase(); updateTranscodeProgressBar(100); const downloaded = formatBytes(progress.downloadedBytes || progress.totalBytes || 0); const total = formatBytes(progress.totalBytes || 0); downloadSizeText.textContent = `${downloaded} / ${total} — 下载完成`; downloadProgressText.textContent = '100%'; downloadProgressFill.style.width = '100%'; setTimeout(() => { showTranscodePhase(); }, 600); } else if (status === 'transcoding') { showTranscodePhase(); const percent = Math.min(Math.max(Math.round(progress.percent || 0), 0), 100); updateTranscodeProgressBar(percent); transcodeProgressText.textContent = `${percent}%`; transcodeProgressFill.style.width = `${percent}%`; transcodeDetailText.textContent = progress.details || 'FFmpeg 转码中...'; if (progress.fps || progress.bitrate || progress.timemark) { transcodeStats.classList.remove('hidden'); statFps.textContent = progress.fps ? `${progress.fps} fps` : ''; statBitrate.textContent = progress.bitrate ? `${progress.bitrate} kbps` : ''; statTime.textContent = progress.timemark ? `${progress.timemark}` : ''; } } else if (status === 'finished') { showTranscodePhase(); updateTranscodeProgressBar(100); transcodeProgressText.textContent = '100%'; transcodeProgressFill.style.width = '100%'; transcodeDetailText.textContent = '转码完成'; } else if (status === 'failed') { updateTranscodeProgressBar(progress.percent || 0); transcodeDetailText.textContent = `失败: ${progress.details || '未知错误'}`; transcodeProgressFill.style.background = 'linear-gradient(90deg, #dc2626, #b91c1c)'; } else if (status === 'cancelled') { updateTranscodeProgressBar(0); transcodeDetailText.textContent = '已取消'; transcodeProgressFill.style.width = '0%'; } }; const showDownloadPhase = () => { if (downloadPhase) downloadPhase.classList.remove('hidden'); if (transcodePhase) transcodePhase.classList.add('hidden'); }; const showTranscodePhase = () => { if (downloadPhase) downloadPhase.classList.add('hidden'); if (transcodePhase) transcodePhase.classList.remove('hidden'); }; const resetPhases = () => { if (downloadPhase) downloadPhase.classList.remove('hidden'); if (downloadSizeText) downloadSizeText.textContent = '准备下载...'; if (downloadProgressText) downloadProgressText.textContent = '0%'; if (downloadProgressFill) downloadProgressFill.style.width = '0%'; if (transcodePhase) transcodePhase.classList.add('hidden'); if (transcodeDetailText) transcodeDetailText.textContent = 'FFmpeg 转码中...'; if (transcodeProgressText) transcodeProgressText.textContent = '0%'; if (transcodeProgressFill) { transcodeProgressFill.style.width = '0%'; transcodeProgressFill.style.background = ''; } if (transcodeStats) transcodeStats.classList.add('hidden'); if (statFps) statFps.textContent = ''; if (statBitrate) statBitrate.textContent = ''; if (statTime) statTime.textContent = ''; updateTranscodeProgressBar(0); if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); stopTranscodeBtn.disabled = false; stopTranscodeBtn.textContent = '停止播放'; } }; // --- Custom seek bar --- const showSeekBar = () => { if (customSeekContainer && videoDuration > 0) { customSeekContainer.classList.remove('hidden'); } }; const hideSeekBar = () => { if (customSeekContainer) customSeekContainer.classList.add('hidden'); }; const updateSeekBarPosition = (absoluteTime) => { if (!seekBar || videoDuration <= 0) return; const ratio = Math.max(0, Math.min(1, absoluteTime / videoDuration)); seekBarProgress.style.width = `${ratio * 100}%`; seekBarHandle.style.left = `${ratio * 100}%`; seekCurrentTime.textContent = formatTime(absoluteTime); }; // Track playback position in the custom seek bar videoPlayer.addEventListener('timeupdate', () => { if (isDraggingSeek || !isStreamActive) return; const absoluteTime = seekOffset + videoPlayer.currentTime; lastAbsolutePlaybackTime = absoluteTime; updateSeekBarPosition(absoluteTime); }); videoPlayer.addEventListener('play', () => { updatePlayControls(); schedulePlaybackChromeHide(); }); videoPlayer.addEventListener('pause', () => { updatePlayControls(); revealPlaybackChrome(); }); videoPlayer.addEventListener('ended', () => { updatePlayControls(); revealPlaybackChrome(); }); videoPlayer.addEventListener('volumechange', updateVolumeControls); // Seek bar interaction: click or drag const getSeekRatio = (e) => { const rect = seekBar.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); }; const handleSeekStart = (e) => { if (!isStreamActive || videoDuration <= 0) return; e.preventDefault(); isDraggingSeek = true; seekBar.classList.add('seeking'); const ratio = getSeekRatio(e); const targetTime = ratio * videoDuration; updateSeekBarPosition(targetTime); }; const handleSeekMove = (e) => { if (!isDraggingSeek) return; e.preventDefault(); const ratio = getSeekRatio(e); const targetTime = ratio * videoDuration; updateSeekBarPosition(targetTime); }; const handleSeekEnd = (e) => { if (!isDraggingSeek) return; isDraggingSeek = false; seekBar.classList.remove('seeking'); const ratio = getSeekRatio(e); const targetTime = ratio * videoDuration; 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); document.addEventListener('mouseup', handleSeekEnd); // Touch support seekBar.addEventListener('touchstart', (e) => { const touch = e.touches[0]; handleSeekStart({ clientX: touch.clientX, preventDefault: () => e.preventDefault() }); }); document.addEventListener('touchmove', (e) => { if (!isDraggingSeek) return; const touch = e.touches[0]; handleSeekMove({ clientX: touch.clientX, preventDefault: () => e.preventDefault() }); }); document.addEventListener('touchend', (e) => { if (!isDraggingSeek) return; const touch = e.changedTouches[0]; handleSeekEnd({ clientX: touch.clientX }); }); } if (playerWrapper) { playerWrapper.addEventListener('mousemove', () => { controlsHovered = true; schedulePlaybackChromeHide(); if (controlsPointerReleaseTimeout) { clearTimeout(controlsPointerReleaseTimeout); } controlsPointerReleaseTimeout = setTimeout(() => { controlsHovered = false; schedulePlaybackChromeHide(); controlsPointerReleaseTimeout = null; }, 1200); }); playerWrapper.addEventListener('mouseenter', () => { controlsHovered = true; revealPlaybackChrome(); }); playerWrapper.addEventListener('mouseleave', () => { controlsHovered = false; schedulePlaybackChromeHide(); }); } if (customControls) { customControls.addEventListener('mouseenter', () => { controlsHovered = true; revealPlaybackChrome(); }); customControls.addEventListener('mouseleave', () => { controlsHovered = false; schedulePlaybackChromeHide(); }); } if (customSeekContainer) { customSeekContainer.addEventListener('mouseenter', () => { controlsHovered = true; revealPlaybackChrome(); }); customSeekContainer.addEventListener('mouseleave', () => { controlsHovered = false; schedulePlaybackChromeHide(); }); } // --- End custom seek bar --- 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'; if (topBannerTitle) topBannerTitle.textContent = title; topBanner.classList.remove('hidden'); document.title = title; populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp'); } 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 setAuthHeaders = (username, password) => { s3Username = typeof username === 'string' ? username : ''; s3Password = typeof password === 'string' ? password : ''; s3AuthHeaders = {}; if (s3Username) s3AuthHeaders['X-S3-Username'] = s3Username; if (s3Password) s3AuthHeaders['X-S3-Password'] = s3Password; }; const setSessionAuth = (sessionId, username) => { s3AuthHeaders = { 'X-Session-ID': sessionId }; localStorage.setItem('sessionId', sessionId); if (username) { localStorage.setItem('username', username); if (videoListHeader) videoListHeader.textContent = `${username} Available Videos`; } }; const clearSessionAuth = () => { s3AuthHeaders = {}; localStorage.removeItem('sessionId'); localStorage.removeItem('username'); }; 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 { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Login failed'); } if (!Array.isArray(data.buckets) || data.buckets.length === 0) { throw new Error('No buckets available for this account'); } setSessionAuth(data.sessionId, data.username); 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); clearSessionAuth(); } finally { if (loginBtn) { loginBtn.disabled = false; loginBtn.textContent = 'Login'; } } }; 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 const tree = {}; data.videos.forEach(vid => { const key = vid.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[part].__hasTranscodeCache = vid.hasTranscodeCache; current[part].__hasDownloadCache = vid.hasDownloadCache; } current = current[part]; }); }); 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 = `
${name}
Video / ${ext}
`; li.addEventListener('click', (e) => { e.stopPropagation(); selectVideo(key, li, hasTranscodeCache, hasDownloadCache); }); return li; }; const renderTree = (node, container) => { for (const [name, value] of Object.entries(node)) { if (name === '__file' || name === '__hasTranscodeCache' || name === '__hasDownloadCache') continue; const childKeys = Object.keys(value).filter(k => k !== '__file' && k !== '__hasTranscodeCache' && k !== '__hasDownloadCache'); 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, value.__hasTranscodeCache, value.__hasDownloadCache)); } renderTree(value, subListContainer); li.appendChild(subListContainer); container.appendChild(li); } else if (isFile) { container.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache)); } } }; renderTree(tree, videoListEl); } catch (err) { console.error(err); loadingSpinner.innerHTML = `

Error: ${err.message}

`; } }; // Handle video selection const selectVideo = (key, listItemNode, hasTranscodeCache = false, hasDownloadCache = false) => { document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active')); listItemNode.classList.add('active'); stopPolling(); playerOverlay.classList.add('hidden'); videoPlayer.classList.add('hidden'); videoPlayer.pause(); videoPlayer.removeAttribute('src'); if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } videoPlayer.load(); isStreamActive = false; videoDuration = 0; seekOffset = 0; if (seekCurrentTime) seekCurrentTime.textContent = formatTime(0); if (seekTotalTime) seekTotalTime.textContent = formatTime(0); hideSeekBar(); hideCustomControls(); setPlaybackStatus('Paused', 'paused'); updatePlayControls(); if (transcodeBtn) { transcodeBtn.disabled = false; transcodeBtn.textContent = '开始播放'; transcodeBtn.classList.remove('hidden'); } if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); stopTranscodeBtn.disabled = false; 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 = ''; 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 = ''; 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 = ''; 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 = ''; subtitlePanel.classList.remove('hidden'); } } catch (err) { console.error('Fetch metadata failed:', err); subtitleSelector.innerHTML = ''; subtitlePanel.classList.remove('hidden'); } finally { subtitleSelector.disabled = false; } }; const startTranscode = async () => { if (!selectedKey) return; if (transcodeBtn) { transcodeBtn.disabled = true; transcodeBtn.textContent = '播放中...'; } if (stopTranscodeBtn) { stopTranscodeBtn.classList.remove('hidden'); stopTranscodeBtn.disabled = false; stopTranscodeBtn.textContent = '停止播放'; } stopPolling(); resetPhases(); seekOffset = 0; videoDuration = 0; isStreamActive = false; hideSeekBar(); hideCustomControls(); setPlaybackStatus('Paused', 'paused'); transcodingOverlay.classList.remove('hidden'); try { if (!selectedBucket) throw new Error('No bucket selected'); const streamUrl = buildHlsPlaylistUrl(); if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } if (window.Hls && Hls.isSupported()) { hlsInstance = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }); 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((e) => { console.warn('[HLS] Autoplay blocked:', e.message); }); showSeekBar(); showCustomControls(); updatePlayControls(); updateVolumeControls(); updateFullscreenControls(); 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; } } }); } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) { videoPlayer.src = streamUrl; videoPlayer.addEventListener('loadedmetadata', () => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); isStreamActive = true; videoPlayer.play().catch(() => { }); showSeekBar(); showCustomControls(); updatePlayControls(); updateVolumeControls(); updateFullscreenControls(); schedulePlaybackChromeHide(); }, { 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 = '停止中...'; 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'); handleProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' }); if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } isStreamActive = false; videoPlayer.pause(); videoPlayer.removeAttribute('src'); videoPlayer.load(); hideSeekBar(); hideCustomControls(); setPlaybackStatus('Paused', 'paused'); updatePlayControls(); if (transcodeBtn) { transcodeBtn.disabled = false; transcodeBtn.textContent = '开始播放'; transcodeBtn.classList.remove('hidden'); } stopTranscodeBtn.classList.add('hidden'); } catch (err) { console.error('Stop transcode failed:', err); alert(`停止播放失败: ${err.message}`); } finally { if (stopTranscodeBtn) { stopTranscodeBtn.disabled = false; 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); currentPollInterval = null; } }; const playMp4Stream = (url) => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); hideCustomControls(); if (playBtn) playBtn.classList.add('hidden'); if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); } resetPhases(); videoPlayer.src = url; videoPlayer.load(); isStreamActive = true; lastAbsolutePlaybackTime = seekOffset; showSeekBar(); showCustomControls(); videoPlayer.addEventListener('loadedmetadata', () => { updateSeekBarPosition(seekOffset); updatePlayControls(); updateVolumeControls(); updateFullscreenControls(); schedulePlaybackChromeHide(); }, { once: true }); }; if (controlPlayToggle) { controlPlayToggle.addEventListener('click', () => { if (!isStreamActive) return; if (videoPlayer.paused) { videoPlayer.play().catch(() => { }); } else { videoPlayer.pause(); } }); } if (controlMuteToggle) { controlMuteToggle.addEventListener('click', () => { if (videoPlayer.muted || videoPlayer.volume === 0) { videoPlayer.muted = false; videoPlayer.volume = lastVolumeBeforeMute > 0 ? lastVolumeBeforeMute : 1; } else { lastVolumeBeforeMute = videoPlayer.volume > 0 ? videoPlayer.volume : lastVolumeBeforeMute; videoPlayer.muted = true; videoPlayer.volume = 0; } updateVolumeControls(); }); } if (volumeSlider) { volumeSlider.addEventListener('input', (event) => { const nextVolume = Math.max(0, Math.min(1, parseFloat(event.target.value))); videoPlayer.muted = nextVolume === 0; videoPlayer.volume = nextVolume; if (nextVolume > 0) { lastVolumeBeforeMute = nextVolume; } updateVolumeControls(); }); } if (playbackSpeed) { playbackSpeed.addEventListener('change', (event) => { const nextRate = Math.max(0.25, Math.min(4, parseFloat(event.target.value) || 1)); videoPlayer.playbackRate = nextRate; updateSpeedControls(); revealPlaybackChrome(); schedulePlaybackChromeHide(); }); } if (controlFullscreenToggle) { controlFullscreenToggle.addEventListener('click', async () => { try { if (document.fullscreenElement) { await document.exitFullscreen(); } else if (playerOverlay?.parentElement && typeof playerOverlay.parentElement.requestFullscreen === 'function') { await playerOverlay.parentElement.requestFullscreen(); } } catch (error) { console.error('Fullscreen toggle failed:', error); } finally { updateFullscreenControls(); } }); } document.addEventListener('fullscreenchange', updateFullscreenControls); updatePlayControls(); updateVolumeControls(); updateFullscreenControls(); updateSpeedControls(); updateTranscodeProgressBar(0); // Bind events refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket)); 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); } if (bucketSelect) { bucketSelect.addEventListener('change', async (event) => { selectedBucket = event.target.value; await fetchVideos(selectedBucket); }); } if (logoutBtn) { logoutBtn.addEventListener('click', async () => { const sessionId = localStorage.getItem('sessionId'); if (sessionId) { try { await fetch('/api/logout', { method: 'POST', headers: { 'X-Session-ID': sessionId } }); } catch(e){} } clearSessionAuth(); location.reload(); }); } if (themeSelector) { themeSelector.addEventListener('change', (e) => { const theme = e.target.value; document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); }); } const initApp = async () => { const savedSession = localStorage.getItem('sessionId'); const savedUsername = localStorage.getItem('username'); const savedTheme = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', savedTheme); if (themeSelector) themeSelector.value = savedTheme; if (savedSession) { setSessionAuth(savedSession, savedUsername); try { const res = await fetch('/api/buckets', { headers: s3AuthHeaders }); if (!res.ok) throw new Error('Session invalid'); const data = await res.json(); renderBuckets(data.buckets); showApp(); selectedBucket = data.buckets[0].Name; if (bucketSelect) bucketSelect.value = selectedBucket; loadConfig(); connectWebSocket(); await fetchVideos(selectedBucket); } catch (err) { console.error('Auto login failed:', err); clearSessionAuth(); showLogin(); } } else { showLogin(); } }; // Initial state: attempt auto login initApp(); });