first commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env.local
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
videos/
|
||||||
149
README.md
Normal file
149
README.md
Normal file
@@ -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
|
||||||
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env.local
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -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"]
|
||||||
77
backend/main.py
Normal file
77
backend/main.py
Normal file
@@ -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)
|
||||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.24.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
python-dotenv==1.0.0
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -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:
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal 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
257
frontend/app.js
Normal 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
21
frontend/index.html
Normal 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
244
frontend/styles.css
Normal 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); }
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user