Skip to content

Instantly share code, notes, and snippets.

@DrSammyD
Created July 31, 2025 02:41
Show Gist options
  • Save DrSammyD/4c012f3e5cdb3e9d534fdaef60ada74c to your computer and use it in GitHub Desktop.
Save DrSammyD/4c012f3e5cdb3e9d534fdaef60ada74c to your computer and use it in GitHub Desktop.
Node Serve video player
<!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