diff --git a/frontend/app.js b/frontend/app.js index 2df5749..62ec21c 100644 --- a/frontend/app.js +++ b/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 = `
${this.escapeHtml(video.filename)}
-
Swipe up for more
+
Scroll for more
`; @@ -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); } } diff --git a/frontend/styles.css b/frontend/styles.css index b0114e3..d847765 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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 {