Smoother scrolling

This commit is contained in:
2026-02-08 10:37:44 -05:00
parent 0abb7398e1
commit 1afae97d09
2 changed files with 123 additions and 95 deletions

View File

@@ -75,6 +75,7 @@ class EncryptedVideoScroller {
async init() {
this.setupEventListeners();
this.setupIntersectionObserver();
await this.loadVideos();
this.renderVideos();
}
@@ -88,9 +89,12 @@ class EncryptedVideoScroller {
// Keyboard
document.addEventListener('keydown', (e) => this.handleKeydown(e));
// Mouse wheel
// Mouse wheel - now allows smooth scroll instead of snapping
document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
// Scroll event to update currentIndex and manage auto-play
this.container.addEventListener('scroll', () => this.handleScroll());
// Toggle header on tap (but not on buttons)
this.container.addEventListener('click', (e) => {
if (!e.target.closest('.download-btn') && !e.target.closest('.video-title')) {
@@ -104,6 +108,51 @@ class EncryptedVideoScroller {
}
}
setupIntersectionObserver() {
// Observer for loading/decryption - starts early
const loadOptions = {
root: this.container,
threshold: 0.05, // Trigger when 5% visible
rootMargin: '50%' // Start loading 50% before video enters viewport
};
this.loadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Video is approaching or becoming visible - start decryption
const video = entry.target.querySelector('video');
const dataId = entry.target.dataset.index;
if (video && this.videos[dataId] && !this.decryptedBlobs[this.videos[dataId].id]) {
// Start decryption early so it's ready when scrolled into view
this.decryptAndPlayVideo(this.videos[dataId], video);
}
}
});
}, loadOptions);
// Observer for playback - ensures video is playing when visible
const playOptions = {
root: this.container,
threshold: 0.5 // Ensure playback when 50%+ visible
};
this.playObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target.querySelector('video');
if (!video) return;
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
// Video is 50%+ visible - ensure it's playing
video.play().catch(e => console.log('Play error:', e));
} else {
// Not visible enough - pause it
video.pause();
}
});
}, playOptions);
}
toggleHeader() {
const header = document.querySelector('.app-header');
header.classList.toggle('hidden');
@@ -136,13 +185,33 @@ class EncryptedVideoScroller {
}
handleWheel(e) {
e.preventDefault();
if (e.deltaY > 0) {
this.nextVideo();
} else {
this.prevVideo();
// Allow smooth scrolling - don't prevent default
// Just let native scroll happen
// Keyboard and swipe will still snap
}
handleScroll() {
// Update currentIndex based on which video is most visible
const containerTop = this.container.getBoundingClientRect().top;
const containerHeight = this.container.clientHeight;
const viewportCenter = containerHeight / 2;
let closestIndex = 0;
let closestDistance = Math.abs(containerHeight);
const items = this.container.querySelectorAll('.video-item');
items.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const itemCenter = rect.top + rect.height / 2 - containerTop;
const distance = Math.abs(itemCenter - viewportCenter);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
this.currentIndex = closestIndex;
}
handleKeydown(e) {
@@ -182,22 +251,27 @@ class EncryptedVideoScroller {
}
renderVideos() {
// Keep only current and adjacent videos in DOM for performance
const startIndex = Math.max(0, this.currentIndex - 1);
const endIndex = Math.min(this.videos.length, this.currentIndex + 2);
// Render all videos in DOM for smooth scrolling
// Clear existing videos
const existingItems = this.container.querySelectorAll('.video-item');
existingItems.forEach(item => item.remove());
// Render videos
for (let i = startIndex; i < endIndex; i++) {
// Render all videos
for (let i = 0; i < this.videos.length; i++) {
if (this.videos[i]) {
this.renderVideo(this.videos[i], i);
}
}
this.updateActiveVideo();
// Scroll to current video
this.scrollToVideo(this.currentIndex);
}
scrollToVideo(index) {
const item = this.container.querySelector(`[data-index="${index}"]`);
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
renderVideo(video, index) {
@@ -210,7 +284,7 @@ class EncryptedVideoScroller {
videoEl.preload = 'auto';
videoEl.controls = false;
videoEl.loop = true;
videoEl.autoplay = index === this.currentIndex;
videoEl.autoplay = false; // Intersection Observer will handle autoplay
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.setAttribute('webkit-playsinline', 'webkit-playsinline');
@@ -224,7 +298,7 @@ class EncryptedVideoScroller {
info.innerHTML = `
<div>
<div class="video-title">${this.escapeHtml(video.filename)}</div>
<div class="video-description">Swipe up for more</div>
<div class="video-description">Scroll for more</div>
</div>
`;
@@ -241,9 +315,12 @@ class EncryptedVideoScroller {
item.appendChild(videoEl);
item.appendChild(info);
// Load encrypted video on demand
if (index === this.currentIndex) {
this.decryptAndPlayVideo(video, videoEl);
// Set up intersection observers for this video
if (this.loadObserver) {
this.loadObserver.observe(item);
}
if (this.playObserver) {
this.playObserver.observe(item);
}
this.container.appendChild(item);
@@ -253,11 +330,15 @@ class EncryptedVideoScroller {
try {
if (this.decryptedBlobs[video.id]) {
videoEl.src = this.decryptedBlobs[video.id];
videoEl.play().catch(e => console.log('Play error:', e));
// Don't auto-play - let observers handle it
return;
}
// Only show loading for visible videos
const isVisible = videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight;
if (isVisible) {
this.showLoading();
}
const response = await fetch(
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
@@ -277,12 +358,13 @@ class EncryptedVideoScroller {
this.decryptedBlobs[video.id] = blobUrl;
videoEl.src = blobUrl;
videoEl.play().catch(e => console.log('Play error:', e));
// Don't auto-play - let observers handle it
this.hideLoading();
} catch (error) {
console.error('Decryption error:', error);
if (videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight) {
this.showLoadingError();
} finally {
this.hideLoading();
}
}
}
@@ -333,69 +415,17 @@ class EncryptedVideoScroller {
}
}
updateActiveVideo() {
const items = this.container.querySelectorAll('.video-item');
items.forEach(item => {
const index = parseInt(item.dataset.index);
const video = item.querySelector('video');
item.classList.remove('active', 'prev', 'next');
if (index === this.currentIndex) {
item.classList.add('active');
video.play().catch(e => console.log('Play error:', e));
} else if (index < this.currentIndex) {
item.classList.add('prev');
video.pause();
} else {
item.classList.add('next');
video.pause();
}
});
// Preload next video
this.preloadNextVideo();
}
async preloadNextVideo() {
const nextIndex = this.currentIndex + 1;
if (nextIndex < this.videos.length) {
const nextVideo = this.videos[nextIndex];
// Only preload if not already cached
if (nextVideo && !this.decryptedBlobs[nextVideo.id]) {
try {
// Fetch encrypted video in background
const response = await fetch(
`${this.auth.apiUrl}/api/decrypt-video/${nextVideo.id}`,
{
headers: { 'Authorization': this.auth.getAuthHeader() }
}
);
if (response.ok) {
// Cache the decrypted blob for smooth playback
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
this.decryptedBlobs[nextVideo.id] = blobUrl;
console.debug(`Preloaded video ${nextVideo.id}`);
}
} catch (error) {
console.debug('Preload of next video failed (non-critical):', error);
}
}
}
}
async nextVideo() {
if (this.currentIndex < this.videos.length - 1) {
this.currentIndex++;
this.renderVideos();
this.scrollToVideo(this.currentIndex);
}
}
prevVideo() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.renderVideos();
this.scrollToVideo(this.currentIndex);
}
}

View File

@@ -37,40 +37,38 @@ body {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow-y: scroll;
overflow-x: hidden;
scroll-behavior: smooth;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
}
.video-item {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 0.3s ease-in-out;
min-height: 100vh;
height: 100vh;
position: relative;
scroll-snap-align: start;
scroll-snap-stop: always;
opacity: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
touch-action: none;
z-index: 1;
}
.video-item.active {
opacity: 1;
z-index: 2;
}
.video-item.prev {
opacity: 0;
z-index: 1;
opacity: 1;
}
.video-item.next {
opacity: 0;
z-index: 1;
opacity: 1;
}
video {