Created
July 31, 2025 02:41
-
-
Save DrSammyD/4c012f3e5cdb3e9d534fdaef60ada74c to your computer and use it in GitHub Desktop.
Node Serve video player
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>Video Playlist Player</title> | |
<!-- Video.js CSS --> | |
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet"> | |
<style> | |
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; background: #f0f0f0; } | |
.video-container { max-width: 800px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; } | |
.video-player { max-width: 100%; min-width: 100%; margin: 0 auto; } | |
.controls { display: flex; justify-content: center; gap: 10px; } | |
.playlist { margin-top: 20px; max-height: 300px; overflow-y: auto; } | |
.playlist ul { list-style: none; padding: 0; margin: 0; } | |
.playlist li { padding: 10px; cursor: pointer; background: #fff; margin-bottom: 5px; } | |
.playlist li:hover { background: #e0e0e0; } | |
.playlist li.active { background: #d0e8ff; font-weight: bold; } | |
.history { margin-top: 20px; max-height: 300px; overflow-y: auto; } | |
.history ul { list-style: none; padding: 0; margin: 0; } | |
.history li { padding: 10px; background: #fff; margin-bottom: 5px; } | |
.iframe-container { border: 1px solid #ccc; } | |
.iframe-container iframe { width: 100%; height: 300px; border: none; } | |
.interaction-prompt { | |
display: none; | |
text-align: center; | |
color: red; | |
margin-top: 10px; | |
font-size: 16px; | |
font-weight: bold; | |
padding: 10px; | |
background: #fff; | |
border: 2px solid red; | |
} | |
@media (max-width: 768px) { | |
.interaction-prompt { font-size: 14px; padding: 8px; } | |
.video-container { padding: 10px; } | |
} | |
</style> | |
</head> | |
<body> | |
<div class="video-container"> | |
<video id="video-player" class="video-js vjs-default-skin" controls autoplay> | |
<source src="" type="video/mp4"> | |
Your browser does not support the video tag. | |
</video> | |
<div class="interaction-prompt" id="interaction-prompt"> | |
Please tap the video or a playlist item to enable autoplay. | |
</div> | |
<div class="controls"> | |
<button id="shuffle-toggle">Shuffle Off</button> | |
</div> | |
<div class="playlist"> | |
<h3>Playlist</h3> | |
<ul id="playlist"></ul> | |
</div> | |
<div class="history"> | |
<h3>Play History (Latest First)</h3> | |
<ul id="history-list"></ul> | |
</div> | |
<div class="iframe-container"> | |
<h3>Folder Contents (Manual Selection)</h3> | |
<iframe id="folder-iframe" src="."></iframe> | |
</div> | |
</div> | |
<!-- Video.js JavaScript --> | |
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script> | |
<script> | |
// Variables | |
let player = null; | |
const playlistElement = document.getElementById('playlist'); | |
const historyElement = document.getElementById('history-list'); | |
const shuffleToggle = document.getElementById('shuffle-toggle'); | |
const iframe = document.getElementById('folder-iframe'); | |
const interactionPrompt = document.getElementById('interaction-prompt'); | |
let videoFiles = []; | |
let currentVideoIndex = 0; | |
let hasUserInteracted = false; | |
let currentIframePath = '.'; | |
let checkInterval = null; | |
let isShuffle = false; | |
let playHistory = []; | |
let playTimeout = null; | |
// Supported video file extensions | |
const videoExtensions = ['.mp4', '.webm', '.ogg']; | |
// Function to check if a file is a video | |
function isVideoFile(filename) { | |
return videoExtensions.some(ext => filename.toLowerCase().endsWith(ext)); | |
} | |
// Detect if on mobile | |
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); | |
// Normalize URL to prevent double paths | |
function normalizeUrl(path) { | |
const baseUrl = window.location.href; | |
try { | |
const url = new URL(path, baseUrl); | |
return url.href; | |
} catch (e) { | |
console.error('URL normalization error:', e); | |
return path; | |
} | |
} | |
// Shuffle array function | |
function shuffleArray(array) { | |
for (let i = array.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[array[i], array[j]] = [array[j], array[i]]; | |
} | |
} | |
// Function to fetch and parse directory listing | |
async function loadVideoFiles(path) { | |
try { | |
console.log('Fetching directory listing for path:', path); | |
const response = await fetch(path, { headers: { 'Accept': 'text/html' } }); | |
if (!response.ok) throw new Error(`Failed to fetch directory: ${response.status}`); | |
const text = await response.text(); | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(text, 'text/html'); | |
const links = Array.from(doc.querySelectorAll('a')) | |
.map(a => { | |
const href = a.getAttribute('href'); | |
return href.replace(/^(\.\/|\/)+/, ''); | |
}) | |
.filter(href => isVideoFile(href) || href.endsWith('/')) | |
.map(href => decodeURIComponent(href)); | |
videoFiles = links.filter(href => isVideoFile(href)); | |
console.log('Found video files:', videoFiles); | |
console.log('Updating playlist for path:', path); | |
displayPlaylist(videoFiles); | |
if (videoFiles.length > 0 && !player) { | |
initializePlayer(0); | |
} else if (!videoFiles.length) { | |
console.warn('No video files found in directory.'); | |
displayPlaylist([]); | |
} | |
} catch (error) { | |
console.error('Error fetching directory:', error); | |
displayPlaylist([]); | |
} | |
} | |
// Function to initialize Video.js player with the first video | |
function initializePlayer(index) { | |
if (index < 0 || index >= videoFiles.length || !videoFiles.length) { | |
console.warn('Invalid video index or empty playlist:', index, videoFiles); | |
return; | |
} | |
currentVideoIndex = index; | |
const source = normalizeUrl(videoFiles[index]); | |
console.log('Initializing player with video:', source, 'Index:', index); | |
playHistory.push(videoFiles[index]); | |
if (playHistory.length > 20) playHistory.shift(); | |
updateHistory(); | |
// Initialize Video.js with mobile-friendly options | |
player = videojs('video-player', { | |
autoplay: 'any', | |
preload: 'auto', | |
normalizeAutoplay: true, | |
muted: isMobile, | |
playsinline: true, | |
sources: [{ src: source, type: getMimeType(source) }], | |
fluid: true | |
}); | |
// Define custom buttons | |
const Button = videojs.getComponent('Button'); | |
class NextButton extends Button { | |
constructor(player, options = {}) { | |
super(player, options); | |
this.controlText('Next Video'); | |
} | |
buildCSSClass() { | |
return 'vjs-next-button vjs-control vjs-icon-next-item'; | |
} | |
handleClick() { | |
let nextIndex = currentVideoIndex + 1; | |
if (nextIndex >= videoFiles.length) { | |
if (isShuffle) { | |
shuffleArray(videoFiles); | |
displayPlaylist(videoFiles); | |
} | |
nextIndex = 0; | |
} | |
playVideo(nextIndex); | |
} | |
} | |
videojs.registerComponent('NextButton', NextButton); | |
class PreviousButton extends Button { | |
constructor(player, options = {}) { | |
super(player, options); | |
this.controlText('Previous Video'); | |
} | |
buildCSSClass() { | |
return 'vjs-previous-button vjs-control vjs-icon-previous-item'; | |
} | |
handleClick() { | |
let prevIndex = currentVideoIndex - 1; | |
if (prevIndex < 0) { | |
prevIndex = videoFiles.length - 1; | |
} | |
playVideo(prevIndex); | |
} | |
} | |
videojs.registerComponent('PreviousButton', PreviousButton); | |
// Add to control bar | |
player.controlBar.addChild('PreviousButton', {}, 1); | |
player.controlBar.addChild('NextButton', {}, 2); | |
// Hide initially | |
const prevBtn = player.controlBar.getChild('PreviousButton'); | |
const nextBtn = player.controlBar.getChild('NextButton'); | |
prevBtn.hide(); | |
nextBtn.hide(); | |
// On fullscreen change | |
player.on('fullscreenchange', function() { | |
if (player.isFullscreen()) { | |
prevBtn.show(); | |
nextBtn.show(); | |
} else { | |
prevBtn.hide(); | |
nextBtn.hide(); | |
} | |
}); | |
player.play().then(() => { | |
console.log('Playback started successfully'); | |
if (interactionPrompt) interactionPrompt.style.display = 'none'; | |
startIntervalCheck(); | |
}).catch(err => { | |
console.error('Playback error:', err); | |
if (interactionPrompt) interactionPrompt.style.display = 'block'; | |
}); | |
// Set up event listeners | |
player.on('ended', () => { | |
console.log('Video ended event fired. Current index:', currentVideoIndex); | |
let nextIndex = currentVideoIndex + 1; | |
if (isShuffle) { | |
if (nextIndex >= videoFiles.length) { | |
shuffleArray(videoFiles); | |
displayPlaylist(videoFiles); | |
nextIndex = 0; | |
} | |
} else { | |
nextIndex = nextIndex % videoFiles.length; | |
} | |
currentVideoIndex = nextIndex; | |
console.log('Advancing to next video. Next index:', currentVideoIndex); | |
playVideo(currentVideoIndex); | |
}); | |
player.on('timeupdate', () => { | |
const currentTime = player.currentTime(); | |
const duration = player.duration(); | |
const isEnded = player.ended(); | |
console.log('Timeupdate: Current time:', currentTime, 'Duration:', duration, 'Ended:', isEnded); | |
if (isEnded) { | |
console.log('Player reports ended state. Triggering next video.'); | |
player.trigger('ended'); | |
} else if (currentTime && duration && !isNaN(duration) && currentTime >= duration - 0.1) { | |
console.log('Timeupdate: Video nearly complete. Forcing ended event.'); | |
player.trigger('ended'); | |
} | |
}); | |
player.on('play', () => { | |
console.log('Player state: Playing'); | |
clearTimeout(playTimeout); | |
// No longer set timeout here; moved to loadedmetadata | |
}); | |
player.on('loadedmetadata', () => { | |
console.log('Metadata loaded: Duration:', player.duration()); | |
clearTimeout(playTimeout); | |
const duration = player.duration(); | |
if (duration && !isNaN(duration) && duration > 0) { | |
console.log('Setting timeout for duration:', duration); | |
playTimeout = setTimeout(() => { | |
console.log('Timeout: Video duration exceeded. Forcing next video.'); | |
player.trigger('ended'); | |
}, duration * 1000 + 5000); // Increased buffer to 5 seconds | |
} else { | |
console.log('Duration unavailable, skipping timeout setup'); | |
// Optionally set a long default timeout, e.g., 10 minutes | |
// playTimeout = setTimeout(() => { ... }, 600000); | |
} | |
}); | |
player.on('pause', () => console.log('Player state: Paused')); | |
player.on('error', (e) => console.error('Player error:', e)); | |
player.on('click', () => { | |
hasUserInteracted = true; | |
if (interactionPrompt) interactionPrompt.style.display = 'none'; | |
}); | |
updatePlaylistUI(); | |
} | |
// Function to start interval-based check for video completion | |
function startIntervalCheck() { | |
if (checkInterval) clearInterval(checkInterval); | |
checkInterval = setInterval(() => { | |
if (!player) return; | |
const isEnded = player.ended(); | |
const isPaused = player.paused(); | |
console.log('Interval check: Ended:', isEnded, 'Paused:', isPaused); | |
if (isEnded && !isPaused) { | |
console.log('Interval check: Video ended. Triggering next video.'); | |
player.trigger('ended'); | |
clearInterval(checkInterval); | |
} | |
}, 500); | |
} | |
// Function to play a video by index | |
function playVideo(index) { | |
if (index < 0 || index >= videoFiles.length || !videoFiles.length) { | |
console.warn('Invalid video index or empty playlist:', index, videoFiles); | |
return; | |
} | |
if (!player) { | |
console.warn('Player not initialized. Initializing with index:', index); | |
initializePlayer(index); | |
return; | |
} | |
currentVideoIndex = index; | |
const source = normalizeUrl(videoFiles[index]); | |
console.log('Playing video:', source, 'Index:', index); | |
playHistory.push(videoFiles[index]); | |
if (playHistory.length > 20) playHistory.shift(); | |
updateHistory(); | |
player.reset(); | |
player.src({ src: source, type: getMimeType(source) }); | |
player.load(); | |
// Attempt to play with retry and mute fallback | |
const attemptPlay = (attempt = 1) => { | |
if (isMobile) player.muted(true); // Mute for mobile | |
player.play().then(() => { | |
console.log('Playback started successfully'); | |
if (interactionPrompt) interactionPrompt.style.display = 'none'; | |
startIntervalCheck(); | |
}).catch(err => { | |
console.error(`Playback error (attempt ${attempt}):`, err); | |
if (interactionPrompt) interactionPrompt.style.display = 'block'; | |
if (attempt <= 2) { | |
console.log('Retrying playback with mute...'); | |
player.muted(true); | |
setTimeout(() => attemptPlay(attempt + 1), 1000); // Longer delay for mobile | |
} else { | |
console.warn('Playback failed after retries. Please tap to play.'); | |
} | |
}); | |
}; | |
attemptPlay(); | |
updatePlaylistUI(); | |
} | |
// Function to get MIME type based on file extension | |
function getMimeType(filename) { | |
const ext = filename.toLowerCase().split('.').pop(); | |
switch (ext) { | |
case 'mp4': return 'video/mp4'; | |
case 'webm': return 'video/webm'; | |
case 'ogg': return 'video/ogg'; | |
default: return 'video/mp4'; | |
} | |
} | |
// Update playlist UI to highlight current video | |
function updatePlaylistUI() { | |
const items = playlistElement.querySelectorAll('li'); | |
items.forEach((item, index) => { | |
item.classList.toggle('active', index === currentVideoIndex); | |
}); | |
} | |
// Function to display playlist | |
function displayPlaylist(files) { | |
console.log('Rendering playlist with files:', files); | |
playlistElement.innerHTML = ''; | |
if (files.length === 0) { | |
playlistElement.innerHTML = '<li>No videos found in directory. Use the iframe below to select files.</li>'; | |
return; | |
} | |
files.forEach((file, index) => { | |
const li = document.createElement('li'); | |
li.textContent = file.split('/').pop(); | |
li.onclick = () => { | |
console.log('Playlist item clicked:', file, 'Index:', index); | |
hasUserInteracted = true; | |
if (interactionPrompt) interactionPrompt.style.display = 'none'; | |
playVideo(index); | |
}; | |
if (index === currentVideoIndex) li.classList.add('active'); | |
playlistElement.appendChild(li); | |
}); | |
} | |
// Function to update history | |
function updateHistory() { | |
historyElement.innerHTML = ''; | |
playHistory.slice().reverse().forEach(file => { | |
const li = document.createElement('li'); | |
li.textContent = file.split('/').pop(); | |
historyElement.appendChild(li); | |
}); | |
} | |
// Shuffle toggle handler | |
shuffleToggle.onclick = () => { | |
isShuffle = !isShuffle; | |
shuffleToggle.textContent = `Shuffle ${isShuffle ? 'On' : 'Off'}`; | |
if (isShuffle && videoFiles.length > 0) { | |
const currentFile = videoFiles[currentVideoIndex]; | |
shuffleArray(videoFiles); | |
currentVideoIndex = videoFiles.indexOf(currentFile); | |
displayPlaylist(videoFiles); | |
} | |
}; | |
// Iframe navigation and click handling | |
iframe.addEventListener('load', () => { | |
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; | |
const newPath = iframe.contentWindow.location.pathname.replace(/^\/+/, ''); | |
if (newPath !== currentIframePath) { | |
console.log('Iframe navigated to new path:', newPath); | |
currentIframePath = newPath; | |
loadVideoFiles(newPath || '.'); | |
} | |
iframeDoc.addEventListener('click', (event) => { | |
const target = event.target.closest('a'); | |
if (target && isVideoFile(target.href)) { | |
event.preventDefault(); | |
const href = target.getAttribute('href').replace(/^(\.\/|\/)+/, ''); | |
const normalizedHref = normalizeUrl(href); | |
const filename = decodeURIComponent(href.split('/').pop()); | |
let index = videoFiles.indexOf(filename); | |
if (index === -1) { | |
videoFiles.push(filename); | |
displayPlaylist(videoFiles); | |
index = videoFiles.length - 1; | |
} | |
console.log('Iframe video clicked:', filename, 'Index:', index); | |
hasUserInteracted = true; | |
if (interactionPrompt) interactionPrompt.style.display = 'none'; | |
playVideo(index); | |
} | |
}); | |
}); | |
// Load video files on page load | |
window.onload = () => loadVideoFiles('.'); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment