// Global error handler window.addEventListener('error', (event) => { console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno); }); // Authentication Manager class AuthManager { constructor() { this.token = localStorage.getItem('ticky_token'); this.pin = sessionStorage.getItem('ticky_pin'); const protocol = window.location.protocol; const hostname = window.location.hostname; this.apiUrl = `${protocol}//${hostname}:3001`; } async login(pin) { try { const response = await fetch(`${this.apiUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pin }) }); if (!response.ok) { throw new Error('Invalid PIN'); } const data = await response.json(); this.token = data.token; this.pin = pin; localStorage.setItem('ticky_token', this.token); sessionStorage.setItem('ticky_pin', this.pin); return true; } catch (error) { console.error('Login failed:', error); throw error; } } logout() { this.token = null; this.pin = null; localStorage.removeItem('ticky_token'); sessionStorage.removeItem('ticky_pin'); } isAuthenticated() { return !!this.token && !!this.pin; } getAuthHeader() { return `Bearer ${this.token}`; } } // Encrypted Video Scroller class EncryptedVideoScroller { constructor(auth) { this.auth = auth; this.currentIndex = 0; this.videos = []; this.sequentialVideos = []; this.randomMode = false; this.isLoading = false; this.container = document.getElementById('videoContainer'); this.loadingEl = document.getElementById('loading'); this.randomBtn = document.getElementById('randomBtn'); this.touchStartY = 0; this.touchEndY = 0; this.isDragging = false; this.decryptedBlobs = {}; this.init(); } async init() { this.setupEventListeners(); this.setupIntersectionObserver(); await this.loadVideos(); this.renderVideos(); } setupEventListeners() { // Touch events document.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false }); document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false }); document.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false }); // Keyboard document.addEventListener('keydown', (e) => this.handleKeydown(e)); // 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')) { this.toggleHeader(); } }); // Random mode toggle button if (this.randomBtn) { this.randomBtn.addEventListener('click', () => this.toggleRandomMode()); } } 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'); } handleTouchStart(e) { this.touchStartY = e.changedTouches[0].clientY; this.isDragging = true; } handleTouchEnd(e) { this.touchEndY = e.changedTouches[0].clientY; this.isDragging = false; this.handleSwipe(); } handleSwipe() { const diff = this.touchStartY - this.touchEndY; const minSwipeDistance = 50; if (Math.abs(diff) < minSwipeDistance) return; if (diff > 0) { // Swiped up - next video this.nextVideo(); } else { // Swiped down - previous video this.prevVideo(); } } handleWheel(e) { // 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) { if (e.key === 'ArrowDown' || e.key === ' ') { e.preventDefault(); this.nextVideo(); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.prevVideo(); } } async loadVideos() { try { this.showLoading(); const response = await fetch(`${this.auth.apiUrl}/api/encrypted-videos`, { headers: { 'Authorization': this.auth.getAuthHeader() } }); if (!response.ok) { throw new Error('Failed to load videos'); } const newVideos = await response.json(); this.sequentialVideos = newVideos; this.videos = [...newVideos]; this.randomMode = false; this.updateRandomButton(); } catch (error) { console.error('Error loading videos:', error); this.showLoadingError(); } finally { this.hideLoading(); } } renderVideos() { // Render all videos in DOM for smooth scrolling // Clear existing videos const existingItems = this.container.querySelectorAll('.video-item'); existingItems.forEach(item => item.remove()); // Render all videos for (let i = 0; i < this.videos.length; i++) { if (this.videos[i]) { this.renderVideo(this.videos[i], i); } } // 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) { const item = document.createElement('div'); item.className = 'video-item'; item.dataset.index = index; item.style.minHeight = '100vh'; const videoEl = document.createElement('video'); videoEl.preload = 'auto'; videoEl.controls = false; videoEl.loop = true; videoEl.autoplay = false; // Intersection Observer will handle autoplay videoEl.muted = true; videoEl.playsInline = true; videoEl.setAttribute('webkit-playsinline', 'webkit-playsinline'); videoEl.setAttribute('x5-playsinline', 'x5-playsinline'); videoEl.style.width = '100%'; videoEl.style.height = '100%'; videoEl.style.objectFit = 'contain'; const info = document.createElement('div'); info.className = 'video-info'; info.innerHTML = `
${this.escapeHtml(video.filename)}
Scroll for more
`; const downloadBtn = document.createElement('button'); downloadBtn.className = 'download-btn'; downloadBtn.title = 'Download video'; downloadBtn.innerHTML = '⬇'; downloadBtn.onclick = (e) => { e.stopPropagation(); this.downloadVideo(video); }; info.appendChild(downloadBtn); item.appendChild(videoEl); item.appendChild(info); // 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); } async decryptAndPlayVideo(video, videoEl) { try { if (this.decryptedBlobs[video.id]) { videoEl.src = this.decryptedBlobs[video.id]; // 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}`, { headers: { 'Authorization': this.auth.getAuthHeader() } } ); if (!response.ok) { throw new Error('Failed to decrypt video'); } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); this.decryptedBlobs[video.id] = blobUrl; videoEl.src = blobUrl; // 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(); } } } async downloadVideo(video) { try { // Check if already decrypted let blobUrl = this.decryptedBlobs[video.id]; if (!blobUrl) { // Decrypt the video for download const response = await fetch( `${this.auth.apiUrl}/api/decrypt-video/${video.id}`, { headers: { 'Authorization': this.auth.getAuthHeader() } } ); if (!response.ok) { throw new Error('Failed to download video'); } const blob = await response.blob(); blobUrl = URL.createObjectURL(blob); } // Create a temporary download link const link = document.createElement('a'); link.href = blobUrl; // Use original filename or generate one const filename = video.filename || `video_${video.id}.mp4`; link.download = filename; // Trigger download document.body.appendChild(link); link.click(); document.body.removeChild(link); // Clean up the object URL if it wasn't already cached if (!this.decryptedBlobs[video.id]) { setTimeout(() => URL.revokeObjectURL(blobUrl), 100); } } catch (error) { console.error('Download error:', error); alert('Failed to download video: ' + error.message); } } async nextVideo() { if (this.currentIndex < this.videos.length - 1) { this.currentIndex++; this.scrollToVideo(this.currentIndex); } } prevVideo() { if (this.currentIndex > 0) { this.currentIndex--; this.scrollToVideo(this.currentIndex); } } toggleRandomMode() { this.randomMode = !this.randomMode; if (this.randomMode) { // Shuffle videos array using Fisher-Yates shuffle this.videos = [...this.videos].sort(() => Math.random() - 0.5); } else { // Restore original order this.videos = [...this.sequentialVideos]; } this.currentIndex = 0; this.renderVideos(); this.updateRandomButton(); } updateRandomButton() { if (this.randomBtn) { this.randomBtn.textContent = this.randomMode ? 'Random ▼' : 'Sequential ▼'; } } showLoading() { this.isLoading = true; this.loadingEl.classList.add('show'); this.loadingEl.innerHTML = '
'; } hideLoading() { this.isLoading = false; this.loadingEl.classList.remove('show'); } showLoadingError() { this.loadingEl.innerHTML = 'Failed to load'; this.loadingEl.classList.add('show'); setTimeout(() => this.loadingEl.classList.remove('show'), 3000); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } // App Controller class TickyApp { constructor() { this.auth = new AuthManager(); this.scroller = null; this.init(); } async checkFirstLogin() { try { // Try to access a protected endpoint without auth to see if marker file exists const response = await fetch(`${this.auth.apiUrl}/api/health`); return !response.ok; } catch { return true; // Assume first login if we can't check } } init() { if (this.auth.isAuthenticated()) { this.showApp(); } else { this.showLogin(); } } showLogin() { const loginScreen = document.getElementById('loginScreen'); const mainApp = document.getElementById('mainApp'); loginScreen.classList.add('active'); mainApp.classList.remove('active'); const loginForm = document.getElementById('loginForm'); const pinInput = document.getElementById('pinInput'); const loginError = document.getElementById('loginError'); const setupMode = document.getElementById('setupMode'); // Check if this is first login by attempting to fetch marker file status this.checkFirstLogin().then(isFirstLogin => { if (isFirstLogin) { setupMode.style.display = 'block'; pinInput.placeholder = 'Create 6-digit PIN'; } }); loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const pin = pinInput.value; if (!pin.match(/^\d{6}$/)) { loginError.textContent = 'PIN must be 6 digits'; loginError.style.display = 'block'; return; } try { await this.auth.login(pin); this.showApp(); } catch (error) { loginError.textContent = 'Invalid PIN'; loginError.style.display = 'block'; pinInput.value = ''; } }); } showApp() { const loginScreen = document.getElementById('loginScreen'); const mainApp = document.getElementById('mainApp'); loginScreen.classList.remove('active'); mainApp.classList.add('active'); if (!this.scroller) { this.scroller = new EncryptedVideoScroller(this.auth); } this.setupAppControls(); } setupAppControls() { const logoutBtn = document.getElementById('logoutBtn'); const uploadBtn = document.getElementById('uploadBtn'); const uploadModal = document.getElementById('uploadModal'); const closeUploadBtn = document.getElementById('closeUploadBtn'); const uploadForm = document.getElementById('uploadForm'); const uploadError = document.getElementById('uploadError'); const fileInput = document.getElementById('fileInput'); const dropZone = document.getElementById('dropZone'); const dropOverlay = document.getElementById('dropOverlay'); logoutBtn.addEventListener('click', () => { this.auth.logout(); window.location.reload(); }); uploadBtn.addEventListener('click', () => { uploadModal.classList.add('show'); }); closeUploadBtn.addEventListener('click', () => { uploadModal.classList.remove('show'); }); uploadModal.addEventListener('click', (e) => { if (e.target === uploadModal) { uploadModal.classList.remove('show'); } }); // Drop zone click to select files dropZone.addEventListener('click', () => { fileInput.click(); }); // File input change fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { this.handleFilesSelected(e.target.files); } }); // Drag and drop on drop zone dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files.length > 0) { this.handleFilesSelected(e.dataTransfer.files); } }); // Drag and drop on entire window document.addEventListener('dragover', (e) => { if (uploadModal.classList.contains('show')) { e.preventDefault(); dropOverlay.classList.add('active'); } }); document.addEventListener('dragleave', (e) => { if (e.clientX === 0 && e.clientY === 0) { dropOverlay.classList.remove('active'); } }); document.addEventListener('drop', (e) => { e.preventDefault(); dropOverlay.classList.remove('active'); }); uploadForm.addEventListener('submit', async (e) => { e.preventDefault(); if (fileInput.files.length === 0) { uploadError.textContent = 'Please select at least one video'; uploadError.style.display = 'block'; return; } await this.uploadMultipleFiles(fileInput.files, uploadForm, uploadError); }); } async handleFilesSelected(files) { const uploadForm = document.getElementById('uploadForm'); const uploadError = document.getElementById('uploadError'); uploadError.textContent = ''; uploadError.style.display = 'none'; await this.uploadMultipleFiles(files, uploadForm, uploadError); } async uploadMultipleFiles(files, uploadForm, uploadError) { const progressContainer = document.getElementById('uploadProgress'); const progressList = document.getElementById('progressList'); if (files.length === 0) { uploadError.textContent = 'Please select at least one video'; uploadError.style.display = 'block'; return; } progressList.innerHTML = ''; progressContainer.style.display = 'block'; let successCount = 0; let errorCount = 0; for (let i = 0; i < files.length; i++) { const file = files[i]; const progressItem = document.createElement('div'); progressItem.className = 'progress-item'; progressItem.innerHTML = `
${this.escapeHtml(file.name)}
`; progressList.appendChild(progressItem); try { await this.uploadSingleFile(file, progressItem); progressItem.classList.add('success'); successCount++; } catch (error) { console.error('Upload error:', error); progressItem.classList.add('error'); errorCount++; } } setTimeout(() => { progressContainer.style.display = 'none'; uploadForm.reset(); if (errorCount === 0) { const msg = `Successfully uploaded ${successCount} video${successCount !== 1 ? 's' : ''}!`; alert(msg); document.getElementById('uploadModal').classList.remove('show'); // Reload videos this.scroller.loadVideos().then(() => { this.scroller.renderVideos(); }); } else { uploadError.textContent = `Upload complete: ${successCount} succeeded, ${errorCount} failed`; uploadError.style.display = 'block'; } }, 1000); } async uploadSingleFile(file, progressItem) { const progressFill = progressItem.querySelector('.progress-item-fill'); const formData = new FormData(); formData.append('file', file); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = (e.loaded / e.total) * 100; progressFill.style.width = percentComplete + '%'; } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { progressFill.style.width = '100%'; resolve(); } else { reject(new Error('Upload failed: ' + xhr.statusText)); } }); xhr.addEventListener('error', () => { reject(new Error('Network error')); }); xhr.addEventListener('abort', () => { reject(new Error('Upload cancelled')); }); xhr.open('POST', `${this.auth.apiUrl}/api/upload`); xhr.setRequestHeader('Authorization', this.auth.getAuthHeader()); xhr.send(formData); }); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } // Initialize on page load document.addEventListener('DOMContentLoaded', () => { new TickyApp(); });