// 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(); 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 document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false }); // 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()); } } 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) { e.preventDefault(); if (e.deltaY > 0) { this.nextVideo(); } else { this.prevVideo(); } } 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() { // 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); // Clear existing videos const existingItems = this.container.querySelectorAll('.video-item'); existingItems.forEach(item => item.remove()); // Render videos for (let i = startIndex; i < endIndex; i++) { if (this.videos[i]) { this.renderVideo(this.videos[i], i); } } this.updateActiveVideo(); } 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 = index === this.currentIndex; 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)}
Swipe up 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); // Load encrypted video on demand if (index === this.currentIndex) { this.decryptAndPlayVideo(video, videoEl); } this.container.appendChild(item); } async decryptAndPlayVideo(video, videoEl) { try { if (this.decryptedBlobs[video.id]) { videoEl.src = this.decryptedBlobs[video.id]; videoEl.play().catch(e => console.log('Play error:', e)); return; } 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; videoEl.play().catch(e => console.log('Play error:', e)); } catch (error) { console.error('Decryption error:', error); this.showLoadingError(); } finally { this.hideLoading(); } } 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); } } 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(); } } prevVideo() { if (this.currentIndex > 0) { this.currentIndex--; this.renderVideos(); } } 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(); });