first commit

This commit is contained in:
2026-02-01 04:39:12 -05:00
commit bb35db384f
11 changed files with 819 additions and 0 deletions

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:18-alpine AS build
WORKDIR /app
# Frontend doesn't need a build step for this simple setup
COPY . .
# Serve with a simple HTTP server
RUN npm install -g http-server
EXPOSE 3000
CMD ["http-server", ".", "-p", "3000", "-c-1"]

257
frontend/app.js Normal file
View File

@@ -0,0 +1,257 @@
// Add global error handler
window.addEventListener('error', (event) => {
console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno);
});
class VideoScroller {
constructor() {
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.lastLoadTime = 0;
this.minLoadInterval = 500;
// Get API URL from current location
const protocol = window.location.protocol;
const hostname = window.location.hostname;
this.apiUrl = `${protocol}//${hostname}:3222`;
this.init();
}
async init() {
this.setupEventListeners();
await this.loadVideos();
this.renderVideos();
}
setupEventListeners() {
// Touch events for swipe - prevent default to avoid page scrolling
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 navigation
document.addEventListener('keydown', (e) => this.handleKeydown(e));
// Infinite scroll - load more when nearing the end
this.container.addEventListener('scroll', () => this.handleScroll());
// Mouse wheel
document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
}
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.apiUrl}/api/videos?limit=10`);
const newVideos = await response.json();
if (Array.isArray(newVideos)) {
this.videos.push(...newVideos);
}
this.lastLoadTime = Date.now();
} 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.src = `${this.apiUrl}${video.url}`;
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 = `<div class="video-title">${this.escapeHtml(video.title)}</div>`;
item.appendChild(videoEl);
item.appendChild(info);
// Lazy load: only play current video
if (index === this.currentIndex) {
videoEl.play().catch(e => console.log('Play error:', e));
}
this.container.appendChild(item);
}
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();
}
preloadNextVideo() {
const nextIndex = this.currentIndex + 1;
if (nextIndex < this.videos.length && nextIndex > this.currentIndex + 1) {
// Preload video metadata
const nextVideo = this.videos[nextIndex];
if (nextVideo) {
const img = new Image();
img.src = `${this.apiUrl}${nextVideo.url}`;
}
}
}
async nextVideo() {
if (this.currentIndex < this.videos.length - 1) {
this.currentIndex++;
this.renderVideos();
}
// Load more videos when near the end
const now = Date.now();
if (this.currentIndex > this.videos.length - 3 &&
now - this.lastLoadTime > this.minLoadInterval &&
!this.isLoading) {
await this.loadVideos();
}
}
prevVideo() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.renderVideos();
}
}
handleScroll() {
// Could be used for horizontal scroll on desktop
}
showLoading() {
this.isLoading = true;
this.loadingEl.classList.add('show');
this.loadingEl.innerHTML = '<div class="spinner"></div>';
}
hideLoading() {
this.isLoading = false;
this.loadingEl.classList.remove('show');
}
showLoadingError() {
this.loadingEl.innerHTML = 'Failed to load videos';
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;
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
new VideoScroller();
});

21
frontend/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Ticky">
<title>Ticky - Video Scroller</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="video-container" id="videoContainer">
<!-- Videos will be inserted here -->
</div>
<div class="loading" id="loading">Loading...</div>
</div>
<script src="app.js"></script>
</body>
</html>

244
frontend/styles.css Normal file
View File

@@ -0,0 +1,244 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
-webkit-user-select: none;
user-select: none;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
background: #000;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-height: 100vh;
}
.container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.video-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.video-item {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 0.3s ease-in-out;
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;
}
.video-item.next {
opacity: 0;
z-index: 1;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
max-width: 100%;
max-height: 100%;
-webkit-user-select: none;
user-select: none;
-webkit-media-controls-panel: auto;
}
/* iOS Safari specific */
video::-webkit-media-controls {
display: none;
}
/* Mobile view - portrait */
@media (max-aspect-ratio: 1/1) {
video {
width: 100%;
height: auto;
object-fit: cover;
}
}
/* Desktop view - landscape */
@media (min-aspect-ratio: 1/1) {
video {
width: auto;
height: 100%;
object-fit: cover;
}
}
.video-info {
position: absolute;
bottom: 20px;
left: 20px;
color: white;
z-index: 10;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
pointer-events: none;
}
.video-title {
font-size: clamp(14px, 4vw, 24px);
font-weight: 600;
margin-bottom: 8px;
}
.video-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 15px;
z-index: 10;
}
.control-btn {
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
border: none;
color: white;
font-size: 20px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.control-btn:active {
transform: scale(0.95);
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 18px;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
z-index: 20;
}
.loading.show {
opacity: 1;
}
.loading-bar {
position: absolute;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, #ff006e, #00f5ff);
width: 0%;
transition: width 0.1s linear;
}
.gesture-indicator {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.gesture-indicator.show {
opacity: 1;
}
/* Swipe animation */
.video-item.swipe-up {
animation: slideUp 0.3s ease-out forwards;
}
.video-item.swipe-down {
animation: slideDown 0.3s ease-out forwards;
}
@keyframes slideUp {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
@keyframes slideDown {
from {
transform: translateY(0);
}
to {
transform: translateY(100%);
}
}
/* Loading spinner */
.spinner {
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 3px solid #fff;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}