Last active
November 24, 2024 18:33
-
-
Save enosh/620fee2e6893b651cf136839a445e648 to your computer and use it in GitHub Desktop.
Synced video playback with drag-and-drop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Synced Video Playback</title> | |
| <style> | |
| body { | |
| font-family: SF Pro, Arial, sans-serif; | |
| margin: 0; | |
| padding: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| box-sizing: border-box; | |
| } | |
| .video-container { | |
| display: flex; | |
| position: relative; | |
| box-sizing: border-box; | |
| margin: 0 1em; | |
| } | |
| video { | |
| height: 100%; | |
| min-width: 10%; | |
| } | |
| #presenterVideo, #screenShareVideo { | |
| width: 50%; | |
| } | |
| .resizer { | |
| width: 1em; | |
| cursor: ew-resize; | |
| top: 0; | |
| bottom: 0; | |
| z-index: 10; | |
| } | |
| .resizerBar { | |
| width: 4px; | |
| height: 100%; | |
| margin: auto; | |
| background-color: lightgray; | |
| transition: background-color 0.2s ease; | |
| position: relative; | |
| } | |
| .resizer:hover .resizerBar { | |
| opacity: 1; | |
| background-color: Highlight; | |
| } | |
| .drop-zone { | |
| border: 2px dashed #ccc; | |
| padding: 10px; | |
| text-align: center; | |
| margin-bottom: 10px; | |
| cursor: pointer; | |
| width: 90%; | |
| max-width: 1000px; | |
| } | |
| .controls { | |
| margin-top: 1em; | |
| display: flex; | |
| justify-content: center; | |
| gap: 0.5em; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Synced Video Playback with Drag-and-Drop</h1> | |
| <div class="drop-zone" id="dropZone"> | |
| Drag and drop two videos here (Presenter and Screen Share). | |
| </div> | |
| <div class="video-container"> | |
| <video id="presenterVideo" controls></video> | |
| <div class="resizer" id="resizer"> | |
| <div class="resizerBar"></div> | |
| </div> | |
| <video id="screenShareVideo" controls muted></video> | |
| </div> | |
| <div class="controls"> | |
| <button id="playPause">Play</button> | |
| <button id="mute">Mute</button> | |
| <label for="speed">Speed:</label> | |
| <select id="speed"> | |
| <option value="0.5">0.5x</option> | |
| <option value="1" selected>1x</option> | |
| <option value="1.5">1.5x</option> | |
| <option value="1.75">1.75x</option> | |
| <option value="2">2x</option> | |
| </select> | |
| <button id="skipBack">-10s</button> | |
| <button id="skipForward">+10s</button> | |
| <button id="swapVideos">Swap Videos</button> | |
| </div> | |
| <script> | |
| const presenterVideo = document.getElementById('presenterVideo'); | |
| const screenShareVideo = document.getElementById('screenShareVideo'); | |
| const dropZone = document.getElementById('dropZone'); | |
| const resizer = document.getElementById('resizer'); | |
| const playPauseBtn = document.getElementById('playPause'); | |
| const muteBtn = document.getElementById('mute'); | |
| const speedSelect = document.getElementById('speed'); | |
| let isResizing = false; | |
| let syncInterval = null; | |
| let positionSaveInterval = null; | |
| // Save and load position helpers | |
| const savePosition = (key, position) => { | |
| localStorage.setItem(key, position); | |
| }; | |
| const loadPosition = (key) => { | |
| const position = parseFloat(localStorage.getItem(key)); | |
| return isNaN(position) ? 0 : position; | |
| }; | |
| // Handle drag-and-drop for videos | |
| dropZone.addEventListener('dragover', (event) => { | |
| event.preventDefault(); | |
| dropZone.style.borderColor = '#000'; | |
| }); | |
| dropZone.addEventListener('dragleave', () => { | |
| dropZone.style.borderColor = '#ccc'; | |
| }); | |
| dropZone.addEventListener('drop', (event) => { | |
| event.preventDefault(); | |
| dropZone.style.borderColor = '#ccc'; | |
| const videoFiles = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('video/')); | |
| if (videoFiles.length === 2) { | |
| const presenterFile = videoFiles[0]; | |
| const screenShareFile = videoFiles[1]; | |
| const presenterKey = `video-position-${presenterFile.name}`; | |
| const screenShareKey = `video-position-${screenShareFile.name}`; | |
| presenterVideo.src = URL.createObjectURL(presenterFile); | |
| screenShareVideo.src = URL.createObjectURL(screenShareFile); | |
| presenterVideo.addEventListener('loadedmetadata', () => { | |
| const position = loadPosition(presenterKey); | |
| presenterVideo.currentTime = position < presenterVideo.duration ? position : 0; | |
| }); | |
| screenShareVideo.addEventListener('loadedmetadata', () => { | |
| const position = loadPosition(screenShareKey); | |
| screenShareVideo.currentTime = position < screenShareVideo.duration ? position : 0; | |
| }); | |
| presenterVideo.addEventListener('pause', () => savePosition(presenterKey, presenterVideo.currentTime)); | |
| presenterVideo.addEventListener('ended', () => savePosition(presenterKey, 0)); | |
| screenShareVideo.addEventListener('pause', () => savePosition(screenShareKey, screenShareVideo.currentTime)); | |
| screenShareVideo.addEventListener('ended', () => savePosition(screenShareKey, 0)); | |
| dropZone.textContent = 'Videos loaded. Ready to play!'; | |
| // Save positions every 20 seconds | |
| if (positionSaveInterval) clearInterval(positionSaveInterval); | |
| positionSaveInterval = setInterval(() => { | |
| savePosition(presenterKey, presenterVideo.currentTime); | |
| savePosition(screenShareKey, screenShareVideo.currentTime); | |
| }, 20000); | |
| } else { | |
| alert('Please drop exactly two video files.'); | |
| } | |
| }); | |
| // Play/Pause both videos | |
| playPauseBtn.addEventListener('click', () => { | |
| if (presenterVideo.paused && screenShareVideo.paused) { | |
| presenterVideo.play(); | |
| screenShareVideo.play(); | |
| playPauseBtn.textContent = 'Pause'; | |
| // Set playback speed when clicking play | |
| const speed = parseFloat(speedSelect.value); | |
| presenterVideo.playbackRate = speed; | |
| screenShareVideo.playbackRate = speed; | |
| startSyncing(); | |
| } else { | |
| presenterVideo.pause(); | |
| screenShareVideo.pause(); | |
| playPauseBtn.textContent = 'Play'; | |
| stopSyncing(); | |
| } | |
| }); | |
| // Sync play/pause between both videos | |
| let isPageVisible = true; | |
| // Track visibility change | |
| document.addEventListener('visibilitychange', () => { | |
| isPageVisible = !document.hidden; | |
| }); | |
| const syncPlayPause = (sourceVideo, targetVideo) => { | |
| ['play', 'pause'].forEach(eventType => { | |
| sourceVideo.addEventListener(eventType, () => { | |
| // Allows playback to continue when tab is backgrounded | |
| if (!isPageVisible) return; | |
| if (eventType === 'play' && targetVideo.paused) { | |
| targetVideo.play(); | |
| } else if (eventType === 'pause' && !targetVideo.paused) { | |
| targetVideo.pause(); | |
| } | |
| }); | |
| }); | |
| }; | |
| syncPlayPause(presenterVideo, screenShareVideo); | |
| syncPlayPause(screenShareVideo, presenterVideo); | |
| // Mute/Unmute presenter audio (screen share is muted by default) | |
| muteBtn.addEventListener('click', () => { | |
| const isMuted = presenterVideo.muted; | |
| presenterVideo.muted = !isMuted; | |
| muteBtn.textContent = isMuted ? 'Mute' : 'Unmute'; | |
| }); | |
| // Playback speed | |
| const savePlaybackSpeed = (key, speed) => { | |
| localStorage.setItem(key, speed); | |
| }; | |
| const loadPlaybackSpeed = (key) => { | |
| const speed = parseFloat(localStorage.getItem(key)); | |
| return isNaN(speed) ? 1 : speed; // Default to 1x if no speed is stored | |
| }; | |
| // Load playback speed on initialization | |
| const playbackSpeedKey = 'video-playback-speed'; | |
| const initialSpeed = loadPlaybackSpeed(playbackSpeedKey); | |
| presenterVideo.playbackRate = initialSpeed; | |
| screenShareVideo.playbackRate = initialSpeed; | |
| speedSelect.value = initialSpeed.toString(); | |
| // Change playback speed and save it | |
| speedSelect.addEventListener('change', () => { | |
| const speed = parseFloat(speedSelect.value); | |
| presenterVideo.playbackRate = speed; | |
| screenShareVideo.playbackRate = speed; | |
| savePlaybackSpeed(playbackSpeedKey, speed); | |
| }); | |
| // Skip buttons | |
| const skipBackBtn = document.getElementById('skipBack'); | |
| const skipForwardBtn = document.getElementById('skipForward'); | |
| skipBackBtn.addEventListener('click', () => { | |
| presenterVideo.currentTime = Math.max(0, presenterVideo.currentTime - 10); | |
| screenShareVideo.currentTime = Math.max(0, screenShareVideo.currentTime - 10); | |
| }); | |
| skipForwardBtn.addEventListener('click', () => { | |
| presenterVideo.currentTime = Math.min(presenterVideo.duration, presenterVideo.currentTime + 10); | |
| screenShareVideo.currentTime = Math.min(screenShareVideo.duration, screenShareVideo.currentTime + 10); | |
| }); | |
| const swapVideosBtn = document.getElementById('swapVideos'); | |
| // Swap the videos between presenter and screen share | |
| swapVideosBtn.addEventListener('click', () => { | |
| // Store the current src attributes temporarily | |
| const presenterSrc = presenterVideo.src; | |
| const screenShareSrc = screenShareVideo.src; | |
| // Swap the sources | |
| presenterVideo.src = screenShareSrc; | |
| screenShareVideo.src = presenterSrc; | |
| // Swap the current playback positions | |
| const presenterTime = presenterVideo.currentTime; | |
| presenterVideo.currentTime = screenShareVideo.currentTime; | |
| screenShareVideo.currentTime = presenterTime; | |
| // Ensure that mute status remains correct | |
| screenShareVideo.muted = true; | |
| presenterVideo.muted = false; | |
| }); | |
| // Sync videos periodically | |
| const startSyncing = () => { | |
| if (syncInterval) return; | |
| syncInterval = setInterval(() => { | |
| const presenterTime = presenterVideo.currentTime; | |
| const screenShareTime = screenShareVideo.currentTime; | |
| const drift = Math.abs(presenterTime - screenShareTime); | |
| if (drift > 0.1) { | |
| screenShareVideo.currentTime = presenterTime; | |
| } | |
| }, 100); | |
| }; | |
| const stopSyncing = () => { | |
| clearInterval(syncInterval); | |
| syncInterval = null; | |
| }; | |
| // Resizer logic | |
| resizer.addEventListener('mousedown', (event) => { | |
| isResizing = true; | |
| document.body.style.cursor = 'ew-resize'; | |
| }); | |
| const getMinWidth = (element) => { | |
| const computedStyle = window.getComputedStyle(element); | |
| return parseFloat(computedStyle.minWidth); // Get min-width as a float, already in pixels or percentage | |
| }; | |
| // Resizing logic with min-width and snapping | |
| const snapThreshold = 10; | |
| document.addEventListener('mousemove', (event) => { | |
| if (!isResizing) return; | |
| // Get the width of the resizer from the CSS | |
| const resizerWidth = parseFloat(getComputedStyle(resizer).width); | |
| const containerWidth = document.querySelector('.video-container').clientWidth; | |
| const offsetLeft = document.querySelector('.video-container').offsetLeft; | |
| // Calculate the new width for the presenter video while respecting resizer width | |
| let newPresenterWidth = event.clientX - offsetLeft; | |
| // Check if the resizer is near the center and snap if within the threshold | |
| const centerPosition = containerWidth / 2; | |
| if (Math.abs(newPresenterWidth - centerPosition) < snapThreshold) { | |
| newPresenterWidth = centerPosition; | |
| } | |
| // Ensure the presenter video width doesn't exceed container bounds or become smaller than minimum width | |
| newPresenterWidth = Math.max(newPresenterWidth, getMinWidth(presenterVideo)); | |
| newPresenterWidth = Math.min(newPresenterWidth, containerWidth - getMinWidth(screenShareVideo) - (resizerWidth / 2)); | |
| // Calculate the new width for the screen share video | |
| const newScreenShareWidth = containerWidth - newPresenterWidth; | |
| // Update the widths while maintaining size constraints | |
| presenterVideo.style.flexBasis = `${newPresenterWidth}px`; | |
| screenShareVideo.style.flexBasis = `${newScreenShareWidth}px`; | |
| // Adjust the position of the resizer | |
| resizer.style.left = `${newPresenterWidth}px`; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (isResizing) { | |
| isResizing = false; | |
| document.body.style.cursor = 'default'; | |
| } | |
| }); | |
| const arrowSkipSec = 10; | |
| const jlSkipSec = 5; | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (event) => { | |
| switch (event.key) { | |
| case ' ': // Play/Pause | |
| event.preventDefault(); | |
| playPauseBtn.click(); | |
| break; | |
| case 'm': // Mute/Unmute | |
| muteBtn.click(); | |
| break; | |
| case 'ArrowRight': // Skip forward 5 seconds | |
| presenterVideo.currentTime = Math.min(presenterVideo.duration, presenterVideo.currentTime + jlSkipSec); | |
| screenShareVideo.currentTime = Math.min(screenShareVideo.duration, screenShareVideo.currentTime + jlSkipSec); | |
| break; | |
| case 'ArrowLeft': // Skip backward 5 seconds | |
| presenterVideo.currentTime = Math.max(0, presenterVideo.currentTime - jlSkipSec); | |
| screenShareVideo.currentTime = Math.max(0, screenShareVideo.currentTime - jlSkipSec); | |
| break; | |
| case 'l': // Skip forward 10 seconds | |
| presenterVideo.currentTime = Math.min(presenterVideo.duration, presenterVideo.currentTime + arrowSkipSec); | |
| screenShareVideo.currentTime = Math.min(screenShareVideo.duration, screenShareVideo.currentTime + arrowSkipSec); | |
| break; | |
| case 'j': // Skip backward 10 seconds | |
| presenterVideo.currentTime = Math.max(0, presenterVideo.currentTime - arrowSkipSec); | |
| screenShareVideo.currentTime = Math.max(0, screenShareVideo.currentTime - arrowSkipSec); | |
| break; | |
| case '>': // Increase playback speed | |
| speedSelect.selectedIndex = Math.min(speedSelect.options.length - 1, speedSelect.selectedIndex + 1); | |
| speedSelect.dispatchEvent(new Event('change')); | |
| break; | |
| case '<': // Decrease playback speed | |
| speedSelect.selectedIndex = Math.max(0, speedSelect.selectedIndex - 1); | |
| speedSelect.dispatchEvent(new Event('change')); | |
| break; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment