// 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.isLoading = false;
this.container = document.getElementById('videoContainer');
this.loadingEl = document.getElementById('loading');
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();
}
});
}
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.videos = newVideos;
} 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();
}
}
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();
});