Smoother scrolling
This commit is contained in:
190
frontend/app.js
190
frontend/app.js
@@ -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;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
// 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);
|
||||
this.showLoadingError();
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
if (videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight) {
|
||||
this.showLoadingError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user