diff --git a/public/css/style.css b/public/css/style.css index 6051ef9..2d4d343 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -561,6 +561,7 @@ header p { height: 100%; object-fit: contain; outline: none; + cursor: pointer; } .player-overlay { @@ -607,6 +608,139 @@ header p { font-weight: 600; } +.custom-controls { + margin-top: 0.9rem; + padding: 0.9rem 1rem; + border-radius: 14px; + border: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.72); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + opacity: 1; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.custom-controls.controls-faded, +.custom-seek-container.controls-faded { + opacity: 0; + transform: translateY(8px); + pointer-events: none; +} + +.controls-left, +.controls-right { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.playback-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.8rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.86); + border: 1px solid rgba(148, 163, 184, 0.18); + color: #e2e8f0; + font-size: 0.9rem; + font-weight: 600; +} + +.status-dot { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + background: #f59e0b; + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); +} + +.playback-status.playing .status-dot { + background: #22c55e; + animation: statusPulse 1.8s ease-in-out infinite; +} + +.playback-status.paused .status-dot { + background: #f59e0b; +} + +.playback-status.seeking .status-dot { + background: #38bdf8; + animation: statusPulse 1.1s ease-in-out infinite; +} + +@keyframes statusPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); } + 50% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); } +} + +.control-btn { + appearance: none; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(30, 41, 59, 0.9); + color: #f8fafc; + border-radius: 999px; + padding: 0.65rem 1rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} + +.control-btn:hover { + transform: translateY(-1px); + background: rgba(51, 65, 85, 0.95); + border-color: rgba(96, 165, 250, 0.45); +} + +.control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.volume-control { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 180px; +} + +.volume-slider { + width: 140px; + accent-color: #60a5fa; + cursor: pointer; +} + +.volume-value { + min-width: 3.5rem; + color: rgba(226, 232, 240, 0.88); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +.speed-control { + display: inline-flex; + align-items: center; + gap: 0.55rem; + color: rgba(226, 232, 240, 0.88); + font-size: 0.9rem; + font-weight: 600; +} + +.speed-select { + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(30, 41, 59, 0.9); + color: #f8fafc; + padding: 0.55rem 0.85rem; + outline: none; + cursor: pointer; +} + .progress-info { width: 100%; margin-top: 1rem; @@ -800,4 +934,32 @@ header p { .player-section { order: 1; } -} \ No newline at end of file + + .custom-controls { + align-items: stretch; + } + + .controls-left, + .controls-right { + width: 100%; + } + + .controls-right { + justify-content: flex-end; + } + + .playback-status, + .speed-control { + width: 100%; + } + + .volume-control { + width: 100%; + min-width: 0; + } + + .volume-slider { + flex: 1; + width: auto; + } +} diff --git a/public/index.html b/public/index.html index 1d83722..b55de31 100644 --- a/public/index.html +++ b/public/index.html @@ -80,7 +80,7 @@
-
+

Select a video to start transcoding

@@ -119,7 +119,7 @@
- + +
diff --git a/public/js/main.js b/public/js/main.js index 27532b8..4cd26e6 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -15,12 +15,22 @@ document.addEventListener('DOMContentLoaded', () => { 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 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 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'); @@ -64,6 +74,19 @@ document.addEventListener('DOMContentLoaded', () => { 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 = false; + } + if (playbackSpeed) { + videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1; + } const formatBytes = (bytes) => { if (!bytes || bytes === 0) return '0 B'; @@ -87,6 +110,102 @@ document.addEventListener('DOMContentLoaded', () => { } }; + const updatePlayControls = () => { + if (controlPlayToggle) { + controlPlayToggle.textContent = 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 ? 'Unmute' : 'Mute'; + } + }; + + const updateFullscreenControls = () => { + if (controlFullscreenToggle) { + controlFullscreenToggle.textContent = document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen'; + } + }; + + 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) => { subscribedKey = key; if (wsConnected) { @@ -224,9 +343,55 @@ document.addEventListener('DOMContentLoaded', () => { 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); + + videoPlayer.addEventListener('click', () => { + if (!isStreamActive) return; + + if (videoPlayer.paused) { + videoPlayer.play().catch(() => {}); + return; + } + + videoPlayer.pause(); + }); + + // Browsers usually cannot seek inside a live fragmented MP4 stream. + // When the user drags the native video controls, remap that action to our server-side seek flow. + videoPlayer.addEventListener('seeking', () => { + if (internalSeeking || !isStreamActive || videoDuration <= 0) return; + + const requestedAbsoluteTime = Math.max(0, Math.min(seekOffset + (videoPlayer.currentTime || 0), videoDuration - 0.5)); + const drift = Math.abs(requestedAbsoluteTime - lastAbsolutePlaybackTime); + + if (drift < 1) { + return; + } + + internalSeeking = true; + setPlaybackStatus('Seeking', 'seeking'); + seekToTime(requestedAbsoluteTime); + setTimeout(() => { + internalSeeking = false; + }, 500); + }); + // Seek bar interaction: click or drag const getSeekRatio = (e) => { const rect = seekBar.getBoundingClientRect(); @@ -282,15 +447,63 @@ document.addEventListener('DOMContentLoaded', () => { }); } + 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(); + }); + } + const seekToTime = (targetSeconds) => { if (!selectedKey || !selectedBucket || videoDuration <= 0) return; targetSeconds = Math.max(0, Math.min(targetSeconds, videoDuration - 0.5)); seekOffset = targetSeconds; isStreamActive = false; + lastAbsolutePlaybackTime = targetSeconds; updateSeekBarPosition(targetSeconds); // Show seeking indicator if (seekingOverlay) seekingOverlay.classList.remove('hidden'); + setPlaybackStatus('Seeking', 'seeking'); + revealPlaybackChrome(); if (pendingSeekTimeout) { clearTimeout(pendingSeekTimeout); pendingSeekTimeout = null; @@ -310,7 +523,9 @@ document.addEventListener('DOMContentLoaded', () => { const onCanPlay = () => { if (seekingOverlay) seekingOverlay.classList.add('hidden'); + lastAbsolutePlaybackTime = targetSeconds; videoPlayer.play().catch(() => {}); + updatePlayControls(); videoPlayer.removeEventListener('canplay', onCanPlay); }; videoPlayer.addEventListener('canplay', onCanPlay, { once: true }); @@ -609,6 +824,9 @@ document.addEventListener('DOMContentLoaded', () => { if (seekCurrentTime) seekCurrentTime.textContent = formatTime(0); if (seekTotalTime) seekTotalTime.textContent = formatTime(0); hideSeekBar(); + hideCustomControls(); + setPlaybackStatus('Paused', 'paused'); + updatePlayControls(); if (transcodeBtn) { transcodeBtn.disabled = false; @@ -650,6 +868,8 @@ document.addEventListener('DOMContentLoaded', () => { videoDuration = 0; isStreamActive = false; hideSeekBar(); + hideCustomControls(); + setPlaybackStatus('Paused', 'paused'); transcodingOverlay.classList.remove('hidden'); try { @@ -665,13 +885,14 @@ document.addEventListener('DOMContentLoaded', () => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); isStreamActive = true; + lastAbsolutePlaybackTime = seekOffset; showSeekBar(); + showCustomControls(); updateSeekBarPosition(seekOffset); - if (playBtn) { - playBtn.disabled = false; - playBtn.textContent = 'Play'; - playBtn.classList.remove('hidden'); - } + updatePlayControls(); + updateVolumeControls(); + updateFullscreenControls(); + schedulePlaybackChromeHide(); }, { once: true }); } catch (err) { console.error(err); @@ -699,6 +920,9 @@ document.addEventListener('DOMContentLoaded', () => { videoPlayer.removeAttribute('src'); videoPlayer.load(); hideSeekBar(); + hideCustomControls(); + setPlaybackStatus('Paused', 'paused'); + updatePlayControls(); if (transcodeBtn) { transcodeBtn.disabled = false; transcodeBtn.textContent = 'Start Transcode'; @@ -726,7 +950,8 @@ document.addEventListener('DOMContentLoaded', () => { const playMp4Stream = (url) => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); - playBtn.classList.add('hidden'); + hideCustomControls(); + if (playBtn) playBtn.classList.add('hidden'); if (stopTranscodeBtn) { stopTranscodeBtn.classList.add('hidden'); } @@ -735,17 +960,115 @@ document.addEventListener('DOMContentLoaded', () => { videoPlayer.src = url; videoPlayer.load(); isStreamActive = true; + lastAbsolutePlaybackTime = seekOffset; showSeekBar(); + showCustomControls(); videoPlayer.addEventListener('loadedmetadata', () => { updateSeekBarPosition(seekOffset); - if (playBtn) { - playBtn.disabled = false; - playBtn.textContent = 'Play'; - playBtn.classList.remove('hidden'); - } + 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; + 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); + + document.addEventListener('keydown', (event) => { + if (!isStreamActive) return; + const tagName = event.target?.tagName; + if (tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA' || tagName === 'BUTTON') return; + + if (event.code === 'Space') { + event.preventDefault(); + if (videoPlayer.paused) { + videoPlayer.play().catch(() => {}); + } else { + videoPlayer.pause(); + } + revealPlaybackChrome(); + schedulePlaybackChromeHide(); + return; + } + + if (event.code === 'ArrowRight') { + event.preventDefault(); + seekToTime((seekOffset + (videoPlayer.currentTime || 0)) + 5); + return; + } + + if (event.code === 'ArrowLeft') { + event.preventDefault(); + seekToTime((seekOffset + (videoPlayer.currentTime || 0)) - 5); + } + }); + + updatePlayControls(); + updateVolumeControls(); + updateFullscreenControls(); + // Bind events refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket)); if (clearDownloadCacheBtn) {