624 lines
21 KiB
HTML
624 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Screenshot Timeline Player</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/beercss@3.11.11/dist/cdn/beer.min.css" rel="stylesheet">
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.main-container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 2rem;
|
|
color: white;
|
|
}
|
|
|
|
.controls-card {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.thumbnails-container {
|
|
background: rgba(255,255,255,0.1);
|
|
backdrop-filter: blur(10px);
|
|
border-radius: 0.5rem;
|
|
padding: 1rem;
|
|
margin-top: 1rem;
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.thumbnails-scroll {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0;
|
|
overflow-x: auto;
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.thumbnail {
|
|
min-width: 100px;
|
|
height: 80px;
|
|
border-radius: 0.25rem;
|
|
cursor: pointer;
|
|
object-fit: cover;
|
|
border: 2px solid transparent;
|
|
transition: all 0.3s ease;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.thumbnail:hover {
|
|
opacity: 1;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.thumbnail.active {
|
|
border-color: #ff6b6b;
|
|
opacity: 1;
|
|
box-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
|
|
}
|
|
|
|
.thumbnail.selected {
|
|
border-color: #4caf50;
|
|
background-color: rgba(76, 175, 80, 0.3);
|
|
}
|
|
|
|
.image-viewer {
|
|
background: rgba(255,255,255,0.1);
|
|
backdrop-filter: blur(10px);
|
|
border-radius: 0.5rem;
|
|
padding: 1rem;
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
min-height: 500px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
}
|
|
|
|
.main-image {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.image-info {
|
|
position: absolute;
|
|
top: 1rem;
|
|
left: 1rem;
|
|
background: rgba(0,0,0,0.8);
|
|
color: white;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 1rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.timeline-progress {
|
|
height: 4px;
|
|
background: linear-gradient(90deg, #ff6b6b, #ee5a24);
|
|
border-radius: 2px;
|
|
transition: width 0.3s ease;
|
|
width: 0%;
|
|
margin: 0 0 1rem;
|
|
}
|
|
|
|
.timeline-labels {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.7rem;
|
|
color: rgba(255,255,255,0.8);
|
|
margin-bottom: 0.5rem;
|
|
height: 1.2rem;
|
|
}
|
|
|
|
.control-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.speed-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: white;
|
|
}
|
|
|
|
.placeholder {
|
|
text-align: center;
|
|
color: rgba(255,255,255,0.7);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.file-input {
|
|
display: none;
|
|
}
|
|
|
|
.status {
|
|
color: rgba(255,255,255,0.9);
|
|
margin-left: auto;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.control-group {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.status {
|
|
margin-left: 0;
|
|
text-align: center;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="dark">
|
|
<div class="main-container">
|
|
<div class="header">
|
|
<h1>📸 Screenshot Timeline</h1>
|
|
<p>Play your screenshots in chronological order</p>
|
|
</div>
|
|
|
|
<article class="image-viewer" id="imageContainer">
|
|
<div class="placeholder">
|
|
Select a folder containing screenshots to begin
|
|
</div>
|
|
</article>
|
|
|
|
<div class="thumbnails-container" id="thumbnailsContainer" style="display: none;">
|
|
<div class="thumbnails-scroll" id="thumbnailsScroll">
|
|
<!-- Thumbnails will be added here -->
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<article class="controls-card">
|
|
<div class="status" id="status">No folder selected</div>
|
|
<div class="timeline-progress" id="timelineProgress"></div>
|
|
<div class="timeline-labels" id="timelineLabels" style="display: flex; justify-content: space-between; font-size: 0.8rem; color: #fff; margin-bottom: 0.5rem;"></div>
|
|
<div class="control-group">
|
|
|
|
<button class="button primary" onclick="selectFolder()">
|
|
<i>folder</i>
|
|
<span>Select Folder</span>
|
|
</button>
|
|
<button class="button primary" onclick="goToFirst()" disabled id="firstBtn" class="round-btn" title="First">
|
|
<i>first_page</i>
|
|
</button>
|
|
<button class="button primary" onclick="previousImage()" disabled id="prevBtn" class="round-btn" title="Previous">
|
|
<i>skip_previous</i>
|
|
</button>
|
|
<button class="button primary" id="playBtn" onclick="togglePlay()" disabled>
|
|
<i>play_arrow</i>
|
|
<span>Play</span>
|
|
</button>
|
|
<button class="button primary" onclick="nextImage()" disabled id="nextBtn" class="round-btn" title="Next">
|
|
<i>skip_next</i>
|
|
</button>
|
|
<button class="button primary" onclick="goToLast()" disabled id="lastBtn" class="round-btn" title="Last">
|
|
<i>last_page</i>
|
|
</button>
|
|
|
|
<div class="speed-control">
|
|
<span>Speed:</span>
|
|
<input type="range" id="speedSlider" min="0.5" max="5" step="0.5" value="1" onchange="updateSpeed()">
|
|
<span id="speedValue">1x</span>
|
|
</div>
|
|
|
|
<button class="button danger" id="deleteBtn" onclick="deleteSelectedImages()" disabled>
|
|
<i>delete</i>
|
|
</button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<input type="file" id="folderInput" class="file-input" webkitdirectory>
|
|
|
|
|
|
<script>
|
|
let images = [];
|
|
let currentIndex = 0;
|
|
let isPlaying = false;
|
|
let playInterval = null;
|
|
let playSpeed = 1000;
|
|
let selectedIndices = [];
|
|
|
|
function selectFolder() {
|
|
document.getElementById('folderInput').click();
|
|
}
|
|
|
|
document.getElementById('folderInput').addEventListener('change', async function(e) {
|
|
const files = Array.from(e.target.files);
|
|
await loadImages(files);
|
|
});
|
|
|
|
async function loadImages(files) {
|
|
const imageFiles = files.filter(file =>
|
|
file.type.startsWith('image/') &&
|
|
/\.(png|jpg|jpeg|gif|bmp|webp)$/i.test(file.name)
|
|
);
|
|
|
|
if (imageFiles.length === 0) {
|
|
updateStatus('No image files found');
|
|
return;
|
|
}
|
|
|
|
updateStatus(`Loading ${imageFiles.length} images...`);
|
|
|
|
const imageData = await Promise.all(
|
|
imageFiles.map(async file => ({
|
|
file: file,
|
|
url: URL.createObjectURL(file),
|
|
name: file.name,
|
|
createdDate: new Date(file.lastModified),
|
|
size: file.size
|
|
}))
|
|
);
|
|
|
|
images = imageData.sort((a, b) => a.createdDate - b.createdDate);
|
|
|
|
currentIndex = 0;
|
|
createThumbnails();
|
|
updateButtons();
|
|
displayCurrentImage();
|
|
updateStatus(`Loaded ${images.length} images`);
|
|
}
|
|
|
|
function createThumbnails() {
|
|
const container = document.getElementById('thumbnailsScroll');
|
|
const thumbnailsContainer = document.getElementById('thumbnailsContainer');
|
|
container.innerHTML = '';
|
|
if (images.length === 0) {
|
|
thumbnailsContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
thumbnailsContainer.style.display = 'block';
|
|
images.forEach((img, index) => {
|
|
const thumb = document.createElement('img');
|
|
thumb.src = img.url;
|
|
thumb.className = 'thumbnail';
|
|
thumb.onclick = (e) => handleThumbnailClick(e, index);
|
|
thumb.title = `${img.name} - ${img.createdDate.toLocaleString()}`;
|
|
container.appendChild(thumb);
|
|
});
|
|
updateThumbnailHighlight();
|
|
}
|
|
|
|
function handleThumbnailClick(e, index) {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
// Toggle selection
|
|
if (selectedIndices.includes(index)) {
|
|
selectedIndices = selectedIndices.filter(i => i !== index);
|
|
} else {
|
|
selectedIndices.push(index);
|
|
}
|
|
} else if (e.shiftKey && selectedIndices.length > 0) {
|
|
// Range select
|
|
const last = selectedIndices[selectedIndices.length - 1];
|
|
const [start, end] = [Math.min(last, index), Math.max(last, index)];
|
|
selectedIndices = [];
|
|
for (let i = start; i <= end; i++) selectedIndices.push(i);
|
|
} else {
|
|
// Single select
|
|
selectedIndices = [index];
|
|
}
|
|
updateThumbnailHighlight();
|
|
if (selectedIndices.length === 1) {
|
|
currentIndex = selectedIndices[0];
|
|
displayCurrentImage();
|
|
} else {
|
|
displayCurrentImage();
|
|
}
|
|
updateDeleteButton();
|
|
}
|
|
|
|
function updateThumbnailHighlight() {
|
|
const thumbnails = document.querySelectorAll('.thumbnail');
|
|
thumbnails.forEach((thumb, index) => {
|
|
thumb.classList.toggle('active', index === currentIndex && selectedIndices.length <= 1);
|
|
thumb.classList.toggle('selected', selectedIndices.includes(index));
|
|
});
|
|
// Scroll active thumbnail into view
|
|
if (selectedIndices.length === 1 && thumbnails[selectedIndices[0]]) {
|
|
thumbnails[selectedIndices[0]].scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
inline: 'center'
|
|
});
|
|
}
|
|
}
|
|
|
|
function goToFirst() {
|
|
if (images.length > 0) {
|
|
currentIndex = 0;
|
|
displayCurrentImage();
|
|
}
|
|
}
|
|
|
|
function goToLast() {
|
|
if (images.length > 0) {
|
|
currentIndex = images.length - 1;
|
|
displayCurrentImage();
|
|
}
|
|
}
|
|
|
|
function goToImage(index) {
|
|
if (index >= 0 && index < images.length) {
|
|
currentIndex = index;
|
|
displayCurrentImage();
|
|
updateThumbnailHighlight();
|
|
}
|
|
}
|
|
|
|
function displayCurrentImage() {
|
|
const container = document.getElementById('imageContainer');
|
|
|
|
if (images.length === 0) {
|
|
container.innerHTML = '<div class="placeholder">No images loaded</div>';
|
|
return;
|
|
}
|
|
if (selectedIndices.length > 1) {
|
|
container.innerHTML = `<div class="placeholder">${selectedIndices.length} images selected. Use the Delete Selected button to remove them.</div>`;
|
|
return;
|
|
}
|
|
const current = images[currentIndex];
|
|
const createdDate = current.createdDate.toLocaleString();
|
|
|
|
container.innerHTML = `
|
|
<img src="${current.url}" alt="${current.name}" class="main-image">
|
|
<div class="image-info">
|
|
${currentIndex + 1} / ${images.length}<br>
|
|
${current.name}<br>
|
|
${createdDate}
|
|
</div>
|
|
`;
|
|
|
|
updateTimeline();
|
|
updateThumbnailHighlight();
|
|
}
|
|
|
|
function updateTimeline() {
|
|
const progress = images.length > 0 ? ((currentIndex + 1) / images.length) * 100 : 0;
|
|
document.getElementById('timelineProgress').style.width = progress + '%';
|
|
updateTimelineLabels();
|
|
}
|
|
|
|
function updateTimelineLabels() {
|
|
const labelsContainer = document.getElementById('timelineLabels');
|
|
labelsContainer.innerHTML = '';
|
|
if (images.length === 0) return;
|
|
|
|
const firstDate = images[0].createdDate;
|
|
const lastDate = images[images.length - 1].createdDate;
|
|
const totalDuration = lastDate.getTime() - firstDate.getTime();
|
|
|
|
// Aim for approximately 6-8 labels depending on the container width
|
|
const desiredLabels = Math.min(8, Math.max(6, Math.floor(window.innerWidth / 200)));
|
|
|
|
const labels = [];
|
|
|
|
// Always add the first timestamp
|
|
labels.push({
|
|
time: firstDate,
|
|
pos: 0
|
|
});
|
|
|
|
// Add evenly spaced timestamps
|
|
for (let i = 1; i < desiredLabels - 1; i++) {
|
|
const time = new Date(firstDate.getTime() + (totalDuration * i / (desiredLabels - 1)));
|
|
|
|
// Find closest image to this time
|
|
let closestIdx = 0;
|
|
let minDiff = Math.abs(images[0].createdDate - time);
|
|
|
|
for (let j = 1; j < images.length; j++) {
|
|
const diff = Math.abs(images[j].createdDate - time);
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
closestIdx = j;
|
|
}
|
|
}
|
|
|
|
const pos = (closestIdx / (images.length - 1)) * 100;
|
|
labels.push({
|
|
time: images[closestIdx].createdDate,
|
|
pos: pos
|
|
});
|
|
}
|
|
|
|
// Add the last timestamp
|
|
labels.push({
|
|
time: lastDate,
|
|
pos: 100
|
|
});
|
|
|
|
// Remove any labels that are too close to each other
|
|
const filteredLabels = labels.filter((label, index) => {
|
|
if (index === 0) return true;
|
|
return Math.abs(label.pos - labels[index - 1].pos) >= 5;
|
|
});
|
|
|
|
// Calculate if we need to show seconds (if total duration is less than 5 minutes)
|
|
const showSeconds = totalDuration < 5 * 60 * 1000;
|
|
|
|
// Calculate optimal spacing between labels
|
|
const containerWidth = labelsContainer.offsetWidth;
|
|
const labelWidth = 70; // Approximate width of a time label
|
|
const minSpacing = labelWidth * 1.5; // Minimum space needed between labels
|
|
const maxLabels = Math.floor(containerWidth / minSpacing);
|
|
const targetLabels = Math.min(filteredLabels.length, maxLabels);
|
|
|
|
// Select evenly spaced labels
|
|
const finalLabels = [];
|
|
if (filteredLabels.length > 0) {
|
|
const step = (filteredLabels.length - 1) / (targetLabels - 1);
|
|
for (let i = 0; i < targetLabels; i++) {
|
|
const index = Math.round(i * step);
|
|
finalLabels.push(filteredLabels[index]);
|
|
}
|
|
}
|
|
|
|
// Round the timestamps
|
|
const roundedLabels = finalLabels.map(label => {
|
|
const newTime = new Date(label.time);
|
|
|
|
if (showSeconds) {
|
|
// Round seconds to nearest 5
|
|
const seconds = newTime.getSeconds();
|
|
const roundedSeconds = Math.round(seconds / 5) * 5;
|
|
newTime.setSeconds(roundedSeconds);
|
|
} else {
|
|
// Round minutes to nearest 5 and set seconds to 0
|
|
const minutes = newTime.getMinutes();
|
|
const roundedMinutes = Math.round(minutes / 5) * 5;
|
|
newTime.setMinutes(roundedMinutes);
|
|
newTime.setSeconds(0);
|
|
}
|
|
|
|
return {
|
|
...label,
|
|
time: newTime
|
|
};
|
|
});
|
|
|
|
// Render labels
|
|
labelsContainer.style.position = 'relative';
|
|
labelsContainer.innerHTML = roundedLabels.map(l => {
|
|
const options = showSeconds
|
|
? {hour: '2-digit', minute:'2-digit', second:'2-digit'}
|
|
: {hour: '2-digit', minute:'2-digit'};
|
|
return `<span style="position: absolute; left: ${l.pos}%; transform: translateX(-50%); white-space: nowrap;">
|
|
${l.time.toLocaleTimeString([], options)}
|
|
</span>`;
|
|
}).join('');
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (isPlaying) {
|
|
stopPlay();
|
|
} else {
|
|
startPlay();
|
|
}
|
|
}
|
|
|
|
function startPlay() {
|
|
if (images.length === 0) return;
|
|
|
|
isPlaying = true;
|
|
const playBtn = document.getElementById('playBtn');
|
|
playBtn.innerHTML = '<i>pause</i><span>Pause</span>';
|
|
|
|
playInterval = setInterval(() => {
|
|
if (currentIndex < images.length - 1) {
|
|
nextImage();
|
|
} else {
|
|
stopPlay();
|
|
}
|
|
}, playSpeed);
|
|
}
|
|
|
|
function stopPlay() {
|
|
isPlaying = false;
|
|
const playBtn = document.getElementById('playBtn');
|
|
playBtn.innerHTML = '<i>play_arrow</i><span>Play</span>';
|
|
|
|
if (playInterval) {
|
|
clearInterval(playInterval);
|
|
playInterval = null;
|
|
}
|
|
}
|
|
|
|
function nextImage() {
|
|
if (currentIndex < images.length - 1) {
|
|
currentIndex++;
|
|
displayCurrentImage();
|
|
}
|
|
}
|
|
|
|
function previousImage() {
|
|
if (currentIndex > 0) {
|
|
currentIndex--;
|
|
displayCurrentImage();
|
|
}
|
|
}
|
|
|
|
function updateSpeed() {
|
|
const speed = parseFloat(document.getElementById('speedSlider').value);
|
|
playSpeed = 1000 / speed;
|
|
document.getElementById('speedValue').textContent = speed + 'x';
|
|
|
|
if (isPlaying) {
|
|
clearInterval(playInterval);
|
|
playInterval = setInterval(() => {
|
|
if (currentIndex < images.length - 1) {
|
|
nextImage();
|
|
} else {
|
|
stopPlay();
|
|
}
|
|
}, playSpeed);
|
|
}
|
|
}
|
|
|
|
function updateButtons() {
|
|
const hasImages = images.length > 0;
|
|
document.getElementById('playBtn').disabled = !hasImages;
|
|
document.getElementById('firstBtn').disabled = !hasImages;
|
|
document.getElementById('prevBtn').disabled = !hasImages;
|
|
document.getElementById('nextBtn').disabled = !hasImages;
|
|
document.getElementById('lastBtn').disabled = !hasImages;
|
|
}
|
|
|
|
function updateStatus(message) {
|
|
document.getElementById('status').textContent = message;
|
|
}
|
|
|
|
function updateDeleteButton() {
|
|
document.getElementById('deleteBtn').disabled = selectedIndices.length === 0;
|
|
}
|
|
|
|
function deleteSelectedImages() {
|
|
if (selectedIndices.length === 0) return;
|
|
// Remove selected images
|
|
images = images.filter((_, idx) => !selectedIndices.includes(idx));
|
|
// Reset selection and currentIndex
|
|
selectedIndices = [];
|
|
currentIndex = Math.min(currentIndex, images.length - 1);
|
|
createThumbnails();
|
|
updateButtons();
|
|
displayCurrentImage();
|
|
updateStatus(`Deleted selected images.`);
|
|
updateDeleteButton();
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', function(e) {
|
|
switch(e.code) {
|
|
case 'Space':
|
|
e.preventDefault();
|
|
if (images.length > 0) togglePlay();
|
|
break;
|
|
case 'ArrowLeft':
|
|
previousImage();
|
|
break;
|
|
case 'ArrowRight':
|
|
nextImage();
|
|
break;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |