Smoother scrolling
This commit is contained in:
184
frontend/app.js
184
frontend/app.js
@@ -75,6 +75,7 @@ class EncryptedVideoScroller {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
this.setupIntersectionObserver();
|
||||||
await this.loadVideos();
|
await this.loadVideos();
|
||||||
this.renderVideos();
|
this.renderVideos();
|
||||||
}
|
}
|
||||||
@@ -88,9 +89,12 @@ class EncryptedVideoScroller {
|
|||||||
// Keyboard
|
// Keyboard
|
||||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
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 });
|
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)
|
// Toggle header on tap (but not on buttons)
|
||||||
this.container.addEventListener('click', (e) => {
|
this.container.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.download-btn') && !e.target.closest('.video-title')) {
|
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() {
|
toggleHeader() {
|
||||||
const header = document.querySelector('.app-header');
|
const header = document.querySelector('.app-header');
|
||||||
header.classList.toggle('hidden');
|
header.classList.toggle('hidden');
|
||||||
@@ -136,13 +185,33 @@ class EncryptedVideoScroller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleWheel(e) {
|
handleWheel(e) {
|
||||||
e.preventDefault();
|
// Allow smooth scrolling - don't prevent default
|
||||||
|
// Just let native scroll happen
|
||||||
if (e.deltaY > 0) {
|
// Keyboard and swipe will still snap
|
||||||
this.nextVideo();
|
|
||||||
} else {
|
|
||||||
this.prevVideo();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
handleKeydown(e) {
|
||||||
@@ -182,22 +251,27 @@ class EncryptedVideoScroller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderVideos() {
|
renderVideos() {
|
||||||
// Keep only current and adjacent videos in DOM for performance
|
// Render all videos in DOM for smooth scrolling
|
||||||
const startIndex = Math.max(0, this.currentIndex - 1);
|
|
||||||
const endIndex = Math.min(this.videos.length, this.currentIndex + 2);
|
|
||||||
|
|
||||||
// Clear existing videos
|
// Clear existing videos
|
||||||
const existingItems = this.container.querySelectorAll('.video-item');
|
const existingItems = this.container.querySelectorAll('.video-item');
|
||||||
existingItems.forEach(item => item.remove());
|
existingItems.forEach(item => item.remove());
|
||||||
|
|
||||||
// Render videos
|
// Render all videos
|
||||||
for (let i = startIndex; i < endIndex; i++) {
|
for (let i = 0; i < this.videos.length; i++) {
|
||||||
if (this.videos[i]) {
|
if (this.videos[i]) {
|
||||||
this.renderVideo(this.videos[i], 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) {
|
renderVideo(video, index) {
|
||||||
@@ -210,7 +284,7 @@ class EncryptedVideoScroller {
|
|||||||
videoEl.preload = 'auto';
|
videoEl.preload = 'auto';
|
||||||
videoEl.controls = false;
|
videoEl.controls = false;
|
||||||
videoEl.loop = true;
|
videoEl.loop = true;
|
||||||
videoEl.autoplay = index === this.currentIndex;
|
videoEl.autoplay = false; // Intersection Observer will handle autoplay
|
||||||
videoEl.muted = true;
|
videoEl.muted = true;
|
||||||
videoEl.playsInline = true;
|
videoEl.playsInline = true;
|
||||||
videoEl.setAttribute('webkit-playsinline', 'webkit-playsinline');
|
videoEl.setAttribute('webkit-playsinline', 'webkit-playsinline');
|
||||||
@@ -224,7 +298,7 @@ class EncryptedVideoScroller {
|
|||||||
info.innerHTML = `
|
info.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div class="video-title">${this.escapeHtml(video.filename)}</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -241,9 +315,12 @@ class EncryptedVideoScroller {
|
|||||||
item.appendChild(videoEl);
|
item.appendChild(videoEl);
|
||||||
item.appendChild(info);
|
item.appendChild(info);
|
||||||
|
|
||||||
// Load encrypted video on demand
|
// Set up intersection observers for this video
|
||||||
if (index === this.currentIndex) {
|
if (this.loadObserver) {
|
||||||
this.decryptAndPlayVideo(video, videoEl);
|
this.loadObserver.observe(item);
|
||||||
|
}
|
||||||
|
if (this.playObserver) {
|
||||||
|
this.playObserver.observe(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.container.appendChild(item);
|
this.container.appendChild(item);
|
||||||
@@ -253,11 +330,15 @@ class EncryptedVideoScroller {
|
|||||||
try {
|
try {
|
||||||
if (this.decryptedBlobs[video.id]) {
|
if (this.decryptedBlobs[video.id]) {
|
||||||
videoEl.src = 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show loading for visible videos
|
||||||
|
const isVisible = videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight;
|
||||||
|
if (isVisible) {
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
|
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
|
||||||
@@ -277,12 +358,13 @@ class EncryptedVideoScroller {
|
|||||||
this.decryptedBlobs[video.id] = blobUrl;
|
this.decryptedBlobs[video.id] = blobUrl;
|
||||||
|
|
||||||
videoEl.src = 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) {
|
} catch (error) {
|
||||||
console.error('Decryption error:', error);
|
console.error('Decryption error:', error);
|
||||||
|
if (videoEl.parentElement?.getBoundingClientRect().top < this.container.clientHeight) {
|
||||||
this.showLoadingError();
|
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() {
|
async nextVideo() {
|
||||||
if (this.currentIndex < this.videos.length - 1) {
|
if (this.currentIndex < this.videos.length - 1) {
|
||||||
this.currentIndex++;
|
this.currentIndex++;
|
||||||
this.renderVideos();
|
this.scrollToVideo(this.currentIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevVideo() {
|
prevVideo() {
|
||||||
if (this.currentIndex > 0) {
|
if (this.currentIndex > 0) {
|
||||||
this.currentIndex--;
|
this.currentIndex--;
|
||||||
this.renderVideos();
|
this.scrollToVideo(this.currentIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,40 +37,38 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
overflow-y: scroll;
|
||||||
align-items: center;
|
overflow-x: hidden;
|
||||||
justify-content: center;
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-item {
|
.video-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
position: absolute;
|
height: 100vh;
|
||||||
top: 0;
|
position: relative;
|
||||||
left: 0;
|
scroll-snap-align: start;
|
||||||
opacity: 0;
|
scroll-snap-stop: always;
|
||||||
transition: opacity 0.3s ease-in-out;
|
opacity: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #000;
|
background: #000;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-item.active {
|
.video-item.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-item.prev {
|
.video-item.prev {
|
||||||
opacity: 0;
|
opacity: 1;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-item.next {
|
.video-item.next {
|
||||||
opacity: 0;
|
opacity: 1;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
video {
|
video {
|
||||||
|
|||||||
Reference in New Issue
Block a user