Files
media-slideshow/index.html

911 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media Slideshow</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #000;
color: #fff;
overflow: hidden;
height: 100vh;
}
#container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
#media-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
#media-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.1s ease-out;
cursor: zoom-in;
}
#media-container img.zoomed {
cursor: grab;
}
#media-container img.zoomed:active {
cursor: grabbing;
}
#media-container video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
#virtual-screen {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
padding: 60px;
border-radius: 20px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
#virtual-screen h1 {
font-size: 3em;
margin-bottom: 40px;
text-align: center;
color: #fff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
#virtual-screen h2 {
font-size: 2em;
margin-bottom: 20px;
color: #fff;
}
.folder-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.folder-item {
background: rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 15px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.folder-item:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-5px);
}
.folder-name {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 10px;
}
.folder-count {
font-size: 1.1em;
opacity: 0.9;
}
#info-overlay {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
padding: 15px 20px;
border-radius: 10px;
font-size: 16px;
display: flex;
align-items: center;
gap: 15px;
backdrop-filter: blur(10px);
}
#info-overlay a {
color: #4CAF50;
text-decoration: none;
font-weight: bold;
transition: color 0.3s;
}
#info-overlay a:hover {
color: #66BB6A;
}
#loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
color: #fff;
background: rgba(0, 0, 0, 0.8);
padding: 30px 50px;
border-radius: 15px;
z-index: 1000;
}
.hidden {
display: none !important;
}
#progress {
position: fixed;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
}
/* Scrollbar styling for virtual screen */
#virtual-screen::-webkit-scrollbar {
width: 10px;
}
#virtual-screen::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
#virtual-screen::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
#virtual-screen::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
#file-selector {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.selector-content {
text-align: center;
background: rgba(255, 255, 255, 0.1);
padding: 60px 80px;
border-radius: 20px;
backdrop-filter: blur(10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-height: 90vh;
overflow-y: auto;
}
.selector-content h1 {
font-size: 3em;
margin-bottom: 20px;
color: #fff;
}
.selector-content p {
font-size: 1.2em;
margin-bottom: 30px;
opacity: 0.9;
}
.instructions {
background: rgba(255, 255, 255, 0.15);
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
text-align: left;
max-width: 700px;
}
.instructions h2 {
font-size: 1.5em;
margin-bottom: 15px;
color: #fff;
}
.instructions h3 {
font-size: 1.2em;
margin-top: 20px;
margin-bottom: 10px;
color: #fff;
}
.instructions ul {
margin-left: 20px;
line-height: 1.8;
}
.instructions li {
margin-bottom: 8px;
}
.instructions code {
background: rgba(0, 0, 0, 0.3);
padding: 2px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.instructions .note {
background: rgba(255, 255, 255, 0.1);
padding: 15px;
border-radius: 8px;
margin-top: 15px;
border-left: 4px solid #4CAF50;
}
.button-group {
display: flex;
gap: 15px;
justify-content: center;
align-items: center;
}
#select-button {
background: #fff;
color: #667eea;
border: none;
padding: 15px 40px;
font-size: 1.2em;
font-weight: bold;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
#select-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
#select-button:active {
transform: translateY(0);
}
#download-button {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 2px solid #fff;
padding: 13px 30px;
font-size: 1em;
font-weight: bold;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
#download-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.github-link {
margin-top: 20px;
font-size: 0.9em;
}
.github-link a {
color: #fff;
text-decoration: underline;
}
.github-link a:hover {
opacity: 0.8;
}
/* Scrollbar styling for selector content */
.selector-content::-webkit-scrollbar {
width: 10px;
}
.selector-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.selector-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
.selector-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
</style>
</head>
<body>
<div id="file-selector">
<div class="selector-content">
<h1>📸 Media Slideshow</h1>
<p>A client-side web application for viewing photos and videos</p>
<div class="instructions">
<h2>📖 How to Use</h2>
<h3>Folder Structure</h3>
<ul>
<li>Create folders with numeric prefixes: <code>01_Vacation</code>, <code>02_Family</code>, etc.
</li>
<li>Place your media files inside these folders</li>
<li>Files are displayed in alphabetical order by filename</li>
</ul>
<h3>Supported Formats</h3>
<ul>
<li><strong>Images:</strong> JPEG, PNG, GIF, WebP, AVIF, HEIC/HEIF, BMP, TIFF, SVG</li>
<li><strong>Videos:</strong> MP4, MOV, AVI, MKV, WebM, M4V, WMV, FLV, MPEG, 3GP, OGV</li>
<li>Both uppercase and lowercase file extensions are supported</li>
</ul>
<h3>Features</h3>
<ul>
<li>🖼️ Fullscreen display of images and videos</li>
<li>📅 Automatic EXIF date extraction from photos</li>
<li>📍 GPS location links to Google Maps (when data present)</li>
<li>🔍 Mouse wheel to zoom images (1x - 5x)</li>
<li>⌨️ Arrow keys or <code>&lt;</code> <code>&gt;</code> keys to navigate</li>
<li>📂 Virtual overview screens for easy folder navigation</li>
</ul>
<div class="note">
<strong>🔒 Privacy Note:</strong> All files are processed locally in your browser. Nothing is
uploaded to any server. Your media files never leave your computer. You can download this app and
use it completely offline.
</div>
</div>
<div class="button-group">
<input type="file" id="folder-input" webkitdirectory directory multiple style="display: none;">
<button id="select-button">Select Folder</button>
<button id="download-button" onclick="downloadHTML()">⬇ Download App</button>
</div>
<div class="github-link">
<a href="https://git.mosad.xyz/localhorst/media-slideshow" target="_blank">View on Git</a>
</div>
</div>
</div>
<div id="loading" class="hidden">Processing media files...</div>
<div id="progress" class="hidden"></div>
<div id="container" class="hidden">
<div id="media-container"></div>
</div>
<div id="info-overlay" class="hidden"></div>
<script src="https://cdn.jsdelivr.net/npm/exif-js"></script>
<script>
class MediaSlideshow {
constructor() {
this.mediaFiles = [];
this.currentIndex = 0;
this.zoom = 1;
this.isPanning = false;
this.panStart = { x: 0, y: 0 };
this.panOffset = { x: 0, y: 0 };
this.container = document.getElementById('media-container');
this.infoOverlay = document.getElementById('info-overlay');
this.loading = document.getElementById('loading');
this.progress = document.getElementById('progress');
this.fileSelector = document.getElementById('file-selector');
this.setupFileSelector();
}
setupFileSelector() {
const selectButton = document.getElementById('select-button');
const folderInput = document.getElementById('folder-input');
selectButton.addEventListener('click', () => {
folderInput.click();
});
folderInput.addEventListener('change', async (e) => {
if (e.target.files.length > 0) {
this.fileSelector.classList.add('hidden');
this.loading.classList.remove('hidden');
await this.processFiles(e.target.files);
this.setupEventListeners();
this.loading.classList.add('hidden');
document.getElementById('container').classList.remove('hidden');
this.showMedia(0);
}
});
}
async processFiles(files) {
// Group files by folder
const folderMap = new Map();
// Image formats: JPEG, PNG, GIF, WebP, AVIF, HEIC, BMP, TIFF
// Video formats: MP4, MOV, AVI, MKV, WebM, AVIF (video), M4V, WMV, FLV
const supportedExtensions = [
// Image formats
'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'bmp', 'tiff', 'tif', 'svg',
// Video formats
'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv', 'mpeg', 'mpg', '3gp', 'ogv'
];
for (const file of files) {
const pathParts = file.webkitRelativePath.split('/');
// Check if file is in a subfolder and is a supported type
if (pathParts.length >= 2) {
const folderName = pathParts[pathParts.length - 2];
const extension = file.name.split('.').pop().toLowerCase();
if (supportedExtensions.includes(extension)) {
if (!folderMap.has(folderName)) {
folderMap.set(folderName, []);
}
folderMap.get(folderName).push(file);
}
}
}
// Sort folders by prefix (01_, 02_, etc.)
const sortedFolders = Array.from(folderMap.entries()).sort((a, b) => {
const aMatch = a[0].match(/^(\d+)_/);
const bMatch = b[0].match(/^(\d+)_/);
if (aMatch && bMatch) {
return parseInt(aMatch[1]) - parseInt(bMatch[1]);
}
return a[0].localeCompare(b[0]);
});
// Create overview virtual screen
const folderData = sortedFolders.map(([folderName, files]) => ({
name: folderName.replace(/^\d+_/, ''),
fullName: folderName,
count: files.length
}));
this.mediaFiles.push({
type: 'virtual-overview',
folders: folderData
});
// Process each folder
for (const [folderName, files] of sortedFolders) {
// Sort files by name
files.sort((a, b) => a.name.localeCompare(b.name));
// Add folder virtual screen
this.mediaFiles.push({
type: 'virtual-folder',
name: folderName.replace(/^\d+_/, ''),
fullName: folderName,
count: files.length
});
// Add media files
for (const file of files) {
this.mediaFiles.push({
type: this.getFileType(file.name),
file: file,
folder: folderName
});
}
}
}
getFileType(filename) {
const extension = filename.split('.').pop().toLowerCase();
// Video formats
const videoFormats = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv', 'mpeg', 'mpg', '3gp', 'ogv'];
// Image formats (including AVIF which can be both image and video)
const imageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'bmp', 'tiff', 'tif', 'svg'];
if (videoFormats.includes(extension)) return 'video';
if (imageFormats.includes(extension)) return 'image';
return 'unknown';
}
setupEventListeners() {
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === '<') {
this.previous();
} else if (e.key === 'ArrowRight' || e.key === '>') {
this.next();
}
});
// Mouse wheel for zoom on images
this.container.addEventListener('wheel', (e) => {
const currentMedia = this.mediaFiles[this.currentIndex];
if (currentMedia && currentMedia.type === 'image') {
e.preventDefault();
const img = this.container.querySelector('img');
if (img) {
const delta = e.deltaY > 0 ? 0.9 : 1.1;
this.zoom *= delta;
this.zoom = Math.max(1, Math.min(5, this.zoom));
if (this.zoom > 1) {
img.classList.add('zoomed');
} else {
img.classList.remove('zoomed');
this.panOffset = { x: 0, y: 0 };
}
img.style.transform = `scale(${this.zoom}) translate(${this.panOffset.x}px, ${this.panOffset.y}px)`;
}
}
});
// Mouse/Touch panning for zoomed images
this.container.addEventListener('mousedown', (e) => {
const currentMedia = this.mediaFiles[this.currentIndex];
if (currentMedia && currentMedia.type === 'image' && this.zoom > 1) {
const img = this.container.querySelector('img');
if (img && e.target === img) {
e.preventDefault();
this.isPanning = true;
this.panStart = { x: e.clientX - this.panOffset.x, y: e.clientY - this.panOffset.y };
img.style.cursor = 'grabbing';
}
}
});
this.container.addEventListener('mousemove', (e) => {
if (this.isPanning) {
e.preventDefault();
const img = this.container.querySelector('img');
if (img) {
this.panOffset = {
x: e.clientX - this.panStart.x,
y: e.clientY - this.panStart.y
};
img.style.transform = `scale(${this.zoom}) translate(${this.panOffset.x}px, ${this.panOffset.y}px)`;
}
}
});
this.container.addEventListener('mouseup', () => {
if (this.isPanning) {
this.isPanning = false;
const img = this.container.querySelector('img');
if (img && this.zoom > 1) {
img.style.cursor = 'grab';
}
}
});
this.container.addEventListener('mouseleave', () => {
if (this.isPanning) {
this.isPanning = false;
const img = this.container.querySelector('img');
if (img && this.zoom > 1) {
img.style.cursor = 'grab';
}
}
});
// Touch support for mobile devices
this.container.addEventListener('touchstart', (e) => {
const currentMedia = this.mediaFiles[this.currentIndex];
if (currentMedia && currentMedia.type === 'image' && this.zoom > 1) {
const img = this.container.querySelector('img');
if (img && e.target === img && e.touches.length === 1) {
e.preventDefault();
this.isPanning = true;
this.panStart = {
x: e.touches[0].clientX - this.panOffset.x,
y: e.touches[0].clientY - this.panOffset.y
};
}
}
});
this.container.addEventListener('touchmove', (e) => {
if (this.isPanning && e.touches.length === 1) {
e.preventDefault();
const img = this.container.querySelector('img');
if (img) {
this.panOffset = {
x: e.touches[0].clientX - this.panStart.x,
y: e.touches[0].clientY - this.panStart.y
};
img.style.transform = `scale(${this.zoom}) translate(${this.panOffset.x}px, ${this.panOffset.y}px)`;
}
}
});
this.container.addEventListener('touchend', () => {
this.isPanning = false;
});
// Click to zoom toggle
this.container.addEventListener('click', (e) => {
if (e.target.tagName === 'IMG' && !e.target.closest('#virtual-screen') && !this.isPanning) {
if (this.zoom > 1) {
this.zoom = 1;
this.panOffset = { x: 0, y: 0 };
e.target.classList.remove('zoomed');
e.target.style.transform = 'scale(1) translate(0, 0)';
e.target.style.cursor = 'zoom-in';
} else {
this.zoom = 2;
e.target.classList.add('zoomed');
e.target.style.transform = `scale(${this.zoom})`;
e.target.style.cursor = 'grab';
}
}
});
}
previous() {
this.currentIndex = (this.currentIndex - 1 + this.mediaFiles.length) % this.mediaFiles.length;
this.showMedia(this.currentIndex);
}
next() {
this.currentIndex = (this.currentIndex + 1) % this.mediaFiles.length;
this.showMedia(this.currentIndex);
}
async showMedia(index) {
this.currentIndex = index;
this.zoom = 1;
this.panOffset = { x: 0, y: 0 };
this.container.innerHTML = '';
this.infoOverlay.classList.add('hidden');
this.progress.classList.remove('hidden');
this.progress.textContent = `${index + 1} / ${this.mediaFiles.length}`;
const media = this.mediaFiles[index];
if (media.type === 'virtual-overview') {
this.showVirtualOverview(media);
} else if (media.type === 'virtual-folder') {
this.showVirtualFolder(media);
} else if (media.type === 'image') {
await this.showImage(media);
} else if (media.type === 'video') {
this.showVideo(media);
}
}
showVirtualOverview(media) {
const virtualScreen = document.createElement('div');
virtualScreen.id = 'virtual-screen';
let html = '<h1>Media Slideshow</h1>';
html += '<div class="folder-list">';
media.folders.forEach((folder, idx) => {
// Find the index of this folder's virtual screen
const folderIndex = this.mediaFiles.findIndex(m =>
m.type === 'virtual-folder' && m.fullName === folder.fullName
);
html += `
<div class="folder-item" onclick="slideshow.showMedia(${folderIndex})">
<div class="folder-name">${folder.name}</div>
<div class="folder-count">${folder.count} media file${folder.count !== 1 ? 's' : ''}</div>
</div>
`;
});
html += '</div>';
virtualScreen.innerHTML = html;
this.container.appendChild(virtualScreen);
}
showVirtualFolder(media) {
const virtualScreen = document.createElement('div');
virtualScreen.id = 'virtual-screen';
virtualScreen.innerHTML = `
<h1>${media.name}</h1>
<h2>${media.count} media file${media.count !== 1 ? 's' : ''}</h2>
`;
this.container.appendChild(virtualScreen);
}
async showImage(media) {
const img = document.createElement('img');
const url = URL.createObjectURL(media.file);
img.onload = async () => {
// Try to extract EXIF data
let dateStr = '';
let geoLink = '';
try {
// Check if EXIF library is available
if (typeof EXIF !== 'undefined') {
EXIF.getData(img, async () => {
try {
const exifData = EXIF.getAllTags(img);
// Try to get date from EXIF
if (exifData.DateTimeOriginal) {
dateStr = this.formatExifDate(exifData.DateTimeOriginal);
} else if (exifData.DateTime) {
dateStr = this.formatExifDate(exifData.DateTime);
} else {
// Use file modification date
dateStr = this.formatFileDate(media.file.lastModified);
}
// Check for GPS data
const lat = exifData.GPSLatitude;
const lon = exifData.GPSLongitude;
const latRef = exifData.GPSLatitudeRef;
const lonRef = exifData.GPSLongitudeRef;
if (lat && lon) {
const latDecimal = this.convertDMSToDD(lat, latRef);
const lonDecimal = this.convertDMSToDD(lon, lonRef);
geoLink = `https://www.google.com/maps/place/${latDecimal},${lonDecimal}/@${latDecimal},${lonDecimal},15z/data=!3m1!1e3`;
}
// Show info overlay
this.showInfoOverlay(dateStr, geoLink);
} catch (error) {
console.warn('Error reading EXIF data:', error);
// Fallback to file date
dateStr = this.formatFileDate(media.file.lastModified);
this.showInfoOverlay(dateStr, null);
}
});
} else {
// EXIF library not available, use file date
console.warn('EXIF library not available, using file timestamp');
dateStr = this.formatFileDate(media.file.lastModified);
this.showInfoOverlay(dateStr, null);
}
} catch (error) {
console.error('Error accessing EXIF library:', error);
// Fallback to file date
dateStr = this.formatFileDate(media.file.lastModified);
this.showInfoOverlay(dateStr, null);
}
};
img.src = url;
this.container.appendChild(img);
}
showVideo(media) {
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.src = URL.createObjectURL(media.file);
// Show file date for videos
const dateStr = this.formatFileDate(media.file.lastModified);
this.showInfoOverlay(dateStr, null);
this.container.appendChild(video);
}
showInfoOverlay(dateStr, geoLink) {
this.infoOverlay.classList.remove('hidden');
let html = `<span>${dateStr}</span>`;
if (geoLink) {
html += `<a href="${geoLink}" target="_blank">📍 View on Google Maps</a>`;
}
this.infoOverlay.innerHTML = html;
}
formatExifDate(exifDate) {
// EXIF date format: "YYYY:MM:DD HH:MM:SS"
const parts = exifDate.split(' ');
const dateParts = parts[0].split(':');
const timeParts = parts[1] ? parts[1].split(':') : ['00', '00', '00'];
const date = new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2]),
parseInt(timeParts[0]),
parseInt(timeParts[1]),
parseInt(timeParts[2])
);
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
formatFileDate(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
convertDMSToDD(dms, ref) {
// DMS format: [degrees, minutes, seconds]
const degrees = dms[0];
const minutes = dms[1];
const seconds = dms[2];
let dd = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
dd = dd * -1;
}
return dd;
}
}
// Start the slideshow when page loads
let slideshow;
window.addEventListener('load', () => {
slideshow = new MediaSlideshow();
});
// Function to download the current HTML file
function downloadHTML() {
const htmlContent = document.documentElement.outerHTML;
const blob = new Blob([htmlContent], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'media-slideshow.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
</body>
</html>