commit bb35db384fb87af45782ff664aa102de5e67a424 Author: James Pattinson Date: Sun Feb 1 04:39:12 2026 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e78b5ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env.local +node_modules +__pycache__ +*.pyc +.vscode +.DS_Store +videos/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bf3e55 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Ticky - Self-Hosted TikTok-Style Video Scroller + +A mobile and desktop-friendly infinite scrolling video player built with Python/FastAPI backend and vanilla JavaScript frontend. + +## Features + +- 🎬 Infinite scroll with lazy loading +- 📱 Mobile-friendly swipe navigation +- ⌨️ Keyboard and mouse wheel support +- 🖥️ Responsive design (portrait and landscape) +- 📡 REST API for video management +- 🐳 Docker Compose setup for easy deployment +- ⚡ Fast performance with minimal dependencies + +## Quick Start + +### Prerequisites + +- Docker and Docker Compose + +### Setup + +1. **Place your videos** in the `videos/` folder: + ```bash + cp /path/to/your/videos/*.mp4 ./videos/ + ``` + +2. **Start the application**: + ```bash + docker-compose up -d + ``` + +3. **Access the app**: + - Frontend: http://localhost:3000 + - Backend API: http://localhost:3001/api/videos + +## Project Structure + +``` +ticky/ +├── backend/ # Python/FastAPI backend +│ ├── main.py # FastAPI application +│ ├── requirements.txt # Python dependencies +│ ├── Dockerfile # Backend Docker image +│ └── .dockerignore +├── frontend/ # Vanilla JavaScript frontend +│ ├── index.html # Main HTML file +│ ├── styles.css # Responsive styles +│ ├── app.js # Infinite scroll logic +│ ├── Dockerfile # Frontend Docker image +│ └── package.json +├── videos/ # Video storage (volume mount) +└── docker-compose.yml # Docker Compose configuration +``` + +## Navigation + +### Mobile +- **Swipe up**: Next video +- **Swipe down**: Previous video + +### Desktop +- **Scroll wheel**: Navigate through videos +- **Arrow keys**: Up/Down navigation +- **Space**: Next video + +## API Endpoints + +### Get Random Videos +``` +GET /api/videos?limit=10 +``` + +Response: +```json +[ + { + "id": "unique-id", + "filename": "video.mp4", + "url": "/videos/video.mp4", + "title": "video title" + } +] +``` + +### Health Check +``` +GET /api/health +``` + +## Configuration + +### Backend Environment Variables + +- `VIDEOS_DIR`: Path to videos directory (default: `/videos`) +- `PORT`: Server port (default: `3001`) + +### Supported Video Formats + +- MP4 +- WebM +- OGG +- MOV +- AVI +- MKV + +## Performance Optimizations + +- **Lazy loading**: Only current and adjacent videos are rendered +- **Preloading**: Next video is preloaded for smooth transitions +- **Muted autoplay**: Videos start muted to allow autoplay +- **Efficient DOM**: Minimal DOM manipulation for better performance + +## Customization + +### Styling + +Edit `frontend/styles.css` to customize: +- Colors and themes +- Control button positions +- Video aspect ratio handling +- Animation speeds + +### Backend + +Modify `backend/main.py` to: +- Add video metadata (duration, thumbnails) +- Implement video filtering/sorting +- Add authentication +- Cache results + +## Troubleshooting + +### No videos loading +- Ensure videos are in the `videos/` folder +- Check supported formats (mp4, webm, ogg, mov, avi, mkv) +- Verify permissions: `docker exec ticky-backend ls -la /videos` + +### Videos not playing +- Check browser console for CORS errors +- Verify backend is running: `curl http://localhost:3001/api/health` + +### Build issues +- Rebuild images: `docker-compose down && docker-compose up --build` +- Check Docker logs: `docker-compose logs` + +## License + +MIT diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c9010bf --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +.env.local +.venv +venv diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..baad342 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-alpine + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 3001 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3001"] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..bda69f2 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,77 @@ +import os +import random +from pathlib import Path +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from typing import List + +app = FastAPI() + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +VIDEOS_DIR = Path(os.getenv("VIDEOS_DIR", "/videos")) +VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv"} + +# Mount videos directory +if VIDEOS_DIR.exists(): + app.mount("/videos", StaticFiles(directory=VIDEOS_DIR), name="videos") + + +def get_video_files() -> List[str]: + """Get all video files from the videos directory.""" + if not VIDEOS_DIR.exists(): + return [] + + videos = [ + f.name for f in VIDEOS_DIR.iterdir() + if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS + ] + return videos + + +@app.get("/api/health") +def health_check(): + """Health check endpoint.""" + return {"status": "ok"} + + +@app.get("/api/videos") +def get_videos(limit: int = 10): + """Get a list of random videos.""" + try: + videos = get_video_files() + + if not videos: + return [] + + # Shuffle and get random videos + random.shuffle(videos) + selected = videos[:min(limit, len(videos))] + + video_data = [ + { + "id": f"{int(random.random() * 1e10)}-{i}", + "filename": video, + "url": f"/videos/{video}", + "title": Path(video).stem.replace("-", " ").replace("_", " "), + } + for i, video in enumerate(selected) + ] + + return video_data + except Exception as e: + print(f"Error reading videos: {e}") + return {"error": "Failed to load videos"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=3001) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9376067 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +python-multipart==0.0.6 +python-dotenv==1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31bacc8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: ticky-backend + ports: + - "3222:3001" + environment: + - VIDEOS_DIR=/videos + volumes: + - ./videos:/videos:ro + restart: unless-stopped + + frontend: + build: ./frontend + container_name: ticky-frontend + ports: + - "3111:3000" + depends_on: + - backend + environment: + - API_URL=http://backend:3001 + volumes: + - ./frontend:/app + restart: unless-stopped + +volumes: + videos: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3e4e497 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..e9fcaa1 --- /dev/null +++ b/frontend/app.js @@ -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 = `
${this.escapeHtml(video.title)}
`; + + 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 = '
'; + } + + 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(); +}); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9a2220a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + + + Ticky - Video Scroller + + + +
+
+ +
+
Loading...
+
+ + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..3f001ff --- /dev/null +++ b/frontend/styles.css @@ -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); } +} +