obs-r2-uploader/worker/video-server-enhanced.js

1056 lines
30 KiB
JavaScript

/**
* Enhanced Cloudflare Worker for serving videos from R2 storage
* Features:
* - Admin panel with authentication
* - Video visibility controls (private, shareable, clip_shareable)
* - Clip generation and serving
* - Permission-based access control
*/
// Admin login HTML
const LOGIN_HTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background: #1a1a1a;
padding: 40px;
border-radius: 12px;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 2rem;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #aaa;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 12px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: white;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #3ea6ff;
}
button {
width: 100%;
padding: 12px;
background: #3ea6ff;
border: none;
border-radius: 6px;
color: white;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #2988d8;
}
.error {
background: #e74c3c;
color: white;
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.error.show {
display: block;
}
</style>
</head>
<body>
<div class="login-container">
<h1>🎥 Admin Login</h1>
<div id="error" class="error"></div>
<form id="loginForm">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const error = document.getElementById('error');
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok) {
window.location.href = '/admin';
} else {
error.textContent = 'Invalid password';
error.classList.add('show');
}
} catch (err) {
error.textContent = 'Login failed';
error.classList.add('show');
}
});
</script>
</body>
</html>
`;
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
// CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Admin routes
if (path.startsWith('/admin')) {
return await handleAdminRoutes(request, env, path, corsHeaders);
}
// Clip serving
if (path.startsWith('/clip/')) {
return await handleClip(request, env, path, corsHeaders);
}
// Root path and gallery (show HTML gallery of shareable videos)
if (path === '/' || path === '/gallery') {
return await handlePublicGallery(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
}
// Video player page (/watch/filename)
if (path.startsWith('/watch/')) {
return await handleWatchPage(request, env, path, corsHeaders);
}
// Public API (only lists shareable videos as JSON)
if (path === '/api/list') {
return await handlePublicList(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
}
// Handle HLS live streams (/live/stream-name/file.m3u8 or file.ts)
if (path.startsWith('/live/')) {
return await handleHLSStream(request, env, path, corsHeaders);
}
// Serve video file (with permission check)
const filename = path.substring(1);
if (filename) {
return await handleVideoFile(request, env, filename, corsHeaders);
}
return new Response('Not found', { status: 404, headers: corsHeaders });
} catch (error) {
console.error('Error:', error);
return new Response(`Error: ${error.message}`, { status: 500, headers: corsHeaders });
}
},
};
/**
* Handle admin routes
*/
async function handleAdminRoutes(request, env, path, corsHeaders) {
// Login page
if (path === '/admin/login' && request.method === 'GET') {
return new Response(LOGIN_HTML, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// Login POST
if (path === '/admin/login' && request.method === 'POST') {
const { password } = await request.json();
const correctPassword = env.ADMIN_PASSWORD || 'changeme';
if (password === correctPassword) {
// Create session token
const token = await generateToken(password);
return new Response(JSON.stringify({ success: true }), {
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `admin_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`
}
});
}
return new Response(JSON.stringify({ error: 'Invalid password' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Check authentication for other admin routes
const isAuthenticated = await verifyAuth(request, env);
if (!isAuthenticated) {
return new Response('Unauthorized', {
status: 401,
headers: { 'Location': '/admin/login' }
});
}
// Admin panel
if (path === '/admin' || path === '/admin/') {
const adminHTML = await getAdminHTML();
return new Response(adminHTML, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// API: List all videos with metadata
if (path === '/admin/api/videos') {
return await handleAdminListVideos(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
}
// API: Update video visibility
if (path === '/admin/api/videos/visibility' && request.method === 'POST') {
const { filename, visibility } = await request.json();
await env.VIDEO_METADATA.put(filename, JSON.stringify({ visibility }));
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// API: Delete video
if (path.startsWith('/admin/api/videos/') && request.method === 'DELETE') {
const filename = decodeURIComponent(path.replace('/admin/api/videos/', ''));
await env.R2_BUCKET.delete(filename);
await env.VIDEO_METADATA.delete(filename);
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// API: Upload video
if (path === '/admin/api/upload' && request.method === 'POST') {
return await handleVideoUpload(request, env, corsHeaders);
}
return new Response('Not found', { status: 404 });
}
/**
* Get admin HTML (reads from the admin.html file embedded as string or fetched)
*/
async function getAdminHTML() {
// In production, you'd embed this or fetch from R2
// For now, we'll import it as a module or use a fetch
// Since we can't easily read files in Workers, we'll need to inline it or use a build step
// For this implementation, we'll fetch it from a constant
// In production, use wrangler's module support or embed it
return `
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Admin Panel</title></head>
<body>
<h1>Admin Panel</h1>
<p>Please replace this with the full admin.html content using a build step or module import.</p>
<p>For now, use: <a href="/admin/api/videos">/admin/api/videos</a> to see the API.</p>
</body>
</html>
`;
// TODO: In production, import admin.html content here
}
/**
* List all videos for admin (includes metadata)
*/
async function handleAdminListVideos(bucket, kv, corsHeaders) {
const objects = await bucket.list();
const videos = await Promise.all(
objects.objects.map(async (obj) => {
const metadataStr = await kv.get(obj.key);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
return {
name: obj.key,
size: obj.size,
uploaded: obj.uploaded,
visibility: metadata.visibility || 'shareable',
url: `/${obj.key}`,
};
})
);
return new Response(JSON.stringify({ count: videos.length, videos }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
/**
* List public videos only (shareable ones)
*/
async function handlePublicList(bucket, kv, corsHeaders) {
const objects = await bucket.list();
const videos = await Promise.all(
objects.objects.map(async (obj) => {
const metadataStr = await kv.get(obj.key);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
return {
name: obj.key,
size: obj.size,
uploaded: obj.uploaded,
visibility: metadata.visibility || 'shareable',
url: `/${obj.key}`,
};
})
);
// Filter to only shareable videos
const shareableVideos = videos.filter(v => v.visibility === 'shareable');
return new Response(JSON.stringify({ count: shareableVideos.length, videos: shareableVideos }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
/**
* Serve video file with permission check
*/
async function handleVideoFile(request, env, filename, corsHeaders) {
// Check permissions
const metadataStr = await env.VIDEO_METADATA.get(filename);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
const visibility = metadata.visibility || 'shareable';
// If private, require authentication
if (visibility === 'private') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('This video is private', {
status: 403,
headers: corsHeaders
});
}
}
// If clip_shareable, only allow clips
if (visibility === 'clip_shareable') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('Full video not available. Only clips can be shared.', {
status: 403,
headers: corsHeaders
});
}
}
// Serve the video
return await serveVideo(request, env.R2_BUCKET, filename, corsHeaders);
}
/**
* Handle clip requests
*/
async function handleClip(request, env, path, corsHeaders) {
const filename = path.replace('/clip/', '').split('?')[0];
const url = new URL(request.url);
const start = url.searchParams.get('start') || '0';
const end = url.searchParams.get('end');
// Check permissions
const metadataStr = await env.VIDEO_METADATA.get(filename);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
const visibility = metadata.visibility || 'shareable';
// Clips are allowed for clip_shareable and shareable videos
if (visibility === 'private') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('This video is private', {
status: 403,
headers: corsHeaders
});
}
}
// For actual clip generation, you'd need to:
// 1. Use ffmpeg in a separate service, or
// 2. Use byte-range requests to approximate clips, or
// 3. Pre-generate clips on upload
// For now, we'll serve with Content-Range headers as an approximation
// This won't give exact frame-accurate clips but will seek to the right position
return new Response(`
<html>
<head><title>Video Clip</title></head>
<body style="margin: 0; background: #000;">
<video controls autoplay style="width: 100%; height: 100vh;" preload="auto">
<source src="/${filename}#t=${start}${end ? ',' + end : ''}" type="${getContentType(filename)}">
</video>
</body>
</html>
`, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
/**
* Serve video with range support
*/
async function serveVideo(request, bucket, filename, corsHeaders) {
const range = request.headers.get('Range');
let object;
if (range) {
const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
if (rangeMatch) {
const start = parseInt(rangeMatch[1]);
const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : undefined;
object = await bucket.get(filename, {
range: { offset: start, length: end ? end - start + 1 : undefined }
});
} else {
object = await bucket.get(filename);
}
} else {
object = await bucket.get(filename);
}
if (!object) {
return new Response('Video not found', { status: 404, headers: corsHeaders });
}
const contentType = getContentType(filename);
const headers = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
'Accept-Ranges': 'bytes',
...corsHeaders,
};
if (range && object.range) {
headers['Content-Range'] = `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`;
headers['Content-Length'] = object.range.length;
return new Response(object.body, { status: 206, headers });
} else {
headers['Content-Length'] = object.size;
return new Response(object.body, { status: 200, headers });
}
}
/**
* Public gallery (only shareable videos)
*/
async function handlePublicGallery(bucket, kv, corsHeaders) {
const objects = await bucket.list();
const videos = await Promise.all(
objects.objects.map(async (obj) => {
const metadataStr = await kv.get(obj.key);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
return {
...obj,
visibility: metadata.visibility || 'shareable'
};
})
);
// Filter to only shareable videos AND exclude HLS live stream files
const validVideoExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'];
const shareableVideos = videos.filter(v => {
// Must be shareable
if (v.visibility !== 'shareable') return false;
// Exclude live streaming files
if (v.key.startsWith('live/')) return false;
// Only include actual video files (not HLS chunks)
const ext = v.key.substring(v.key.lastIndexOf('.')).toLowerCase();
return validVideoExtensions.includes(ext);
});
const videoItems = shareableVideos
.map(obj => {
const sizeInMB = (obj.size / (1024 * 1024)).toFixed(2);
const uploadDate = new Date(obj.uploaded).toLocaleDateString();
const encodedKey = encodeURIComponent(obj.key);
return `
<div class="video-item">
<div class="video-thumbnail" onclick="window.location.href='/watch/${encodedKey}'">
<video preload="metadata">
<source src="/${obj.key}#t=0.5" type="${getContentType(obj.key)}">
</video>
<div class="play-overlay">
<div class="play-button">▶</div>
</div>
</div>
<div class="video-info">
<h3 onclick="window.location.href='/watch/${encodedKey}'" style="cursor: pointer;">${obj.key}</h3>
<p>Size: ${sizeInMB} MB | Uploaded: ${uploadDate}</p>
<div class="button-group">
<button onclick="window.location.href='/watch/${encodedKey}'">Watch</button>
<button onclick="copyLink('${obj.key}')">Copy Link</button>
</div>
</div>
</div>
`;
})
.join('\n');
const emptyState = shareableVideos.length === 0 ? `
<div style="text-align: center; padding: 100px 40px; background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); border-radius: 12px; margin: 20px;">
<div style="font-size: 6rem; margin-bottom: 30px; opacity: 0.7; animation: float 3s ease-in-out infinite;">🎬</div>
<h2 style="font-size: 2rem; margin-bottom: 15px; color: #fff;">No Videos Available Yet</h2>
<p style="color: #aaa; font-size: 1.1rem; line-height: 1.6; max-width: 600px; margin: 0 auto 30px;">
This gallery is currently empty. Check back soon for new content!
</p>
<div style="background: #2a2a2a; padding: 30px; border-radius: 10px; max-width: 500px; margin: 40px auto; border: 2px solid #3a3a3a;">
<div style="font-size: 2rem; margin-bottom: 15px;">📢</div>
<h3 style="color: #fff; margin-bottom: 10px;">For Content Creators</h3>
<p style="color: #aaa; font-size: 0.95rem;">
Upload videos through the admin panel to make them available here.
</p>
</div>
</div>
<style>
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
</style>
` : videoItems;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Gallery</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
padding: 20px;
min-height: 100vh;
}
header { text-align: center; margin-bottom: 40px; padding: 20px; }
h1 { font-size: 2.5rem; margin-bottom: 10px; }
.subtitle { color: #aaa; font-size: 1.1rem; }
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 30px;
max-width: 1400px;
margin: 0 auto;
}
.video-item {
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.video-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
}
.video-thumbnail {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
cursor: pointer;
overflow: hidden;
}
.video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.3);
transition: background 0.2s;
}
.video-thumbnail:hover .play-overlay {
background: rgba(0,0,0,0.5);
}
.play-button {
width: 64px;
height: 64px;
background: rgba(62, 166, 255, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
transition: transform 0.2s, background 0.2s;
}
.video-thumbnail:hover .play-button {
transform: scale(1.1);
background: rgba(62, 166, 255, 1);
}
.video-info { padding: 15px; }
.video-info h3 {
font-size: 1rem;
margin-bottom: 8px;
word-break: break-word;
transition: color 0.2s;
}
.video-info h3:hover { color: #3ea6ff; }
.video-info p { color: #aaa; font-size: 0.9rem; margin-bottom: 12px; }
.button-group {
display: flex;
gap: 8px;
}
button {
padding: 8px 16px;
background: #3ea6ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
flex: 1;
}
button:hover { background: #2988d8; }
@media (max-width: 768px) {
.video-grid { grid-template-columns: 1fr; }
h1 { font-size: 2rem; }
}
</style>
</head>
<body>
<header>
<h1>🎥 Video Gallery</h1>
<p class="subtitle">${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available</p>
</header>
<div class="video-grid">
${emptyState}
</div>
<script>
function copyLink(filename) {
const url = window.location.origin + '/' + filename;
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
}
</script>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders }
});
}
/**
* Handle video upload from admin panel
*/
async function handleVideoUpload(request, env, corsHeaders) {
try {
// Parse multipart form data
const formData = await request.formData();
const videoFile = formData.get('video');
if (!videoFile) {
return new Response(JSON.stringify({ error: 'No video file provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// Get filename
const filename = videoFile.name;
// Validate file type
const validExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'];
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
if (!validExtensions.includes(ext)) {
return new Response(JSON.stringify({ error: 'Invalid file type' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// Upload to R2
await env.R2_BUCKET.put(filename, videoFile.stream(), {
httpMetadata: {
contentType: getContentType(filename)
}
});
// Set default visibility to shareable
await env.VIDEO_METADATA.put(filename, JSON.stringify({
visibility: 'shareable',
uploadedAt: new Date().toISOString(),
uploadMethod: 'admin_panel'
}));
return new Response(JSON.stringify({
success: true,
filename: filename,
url: `/${filename}`
}), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
console.error('Upload error:', error);
return new Response(JSON.stringify({
error: 'Upload failed',
details: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
/**
* Verify admin authentication
*/
async function verifyAuth(request, env) {
const cookie = request.headers.get('Cookie') || '';
const match = cookie.match(/admin_auth=([^;]+)/);
if (!match) return false;
const token = match[1];
const correctPassword = env.ADMIN_PASSWORD || 'changeme';
const expectedToken = await generateToken(correctPassword);
return token === expectedToken;
}
/**
* Generate auth token
*/
async function generateToken(password) {
const encoder = new TextEncoder();
const data = encoder.encode(password + 'salt123');
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Handle HLS live stream requests
*/
async function handleHLSStream(request, env, path, corsHeaders) {
// Path format: /live/stream-name/file.m3u8 or /live/stream-name/file.ts
const filename = path.substring(1); // Remove leading slash
try {
const object = await env.R2_BUCKET.get(filename);
if (!object) {
return new Response('Stream not found', {
status: 404,
headers: corsHeaders
});
}
// Determine content type based on extension
const contentType = filename.endsWith('.m3u8')
? 'application/vnd.apple.mpegurl'
: filename.endsWith('.ts')
? 'video/MP2T'
: 'application/octet-stream';
// Set appropriate caching headers
const cacheControl = filename.endsWith('.m3u8')
? 'no-cache, no-store, must-revalidate' // Playlist changes frequently
: 'public, max-age=31536000'; // Chunks are immutable
const headers = {
'Content-Type': contentType,
'Cache-Control': cacheControl,
...corsHeaders,
};
return new Response(object.body, { headers });
} catch (error) {
console.error('Error serving HLS stream:', error);
return new Response('Error serving stream', {
status: 500,
headers: corsHeaders
});
}
}
/**
* Handle watch page (dedicated video player)
*/
async function handleWatchPage(request, env, path, corsHeaders) {
const filename = decodeURIComponent(path.replace('/watch/', ''));
// Check if video exists and get metadata
const metadataStr = await env.VIDEO_METADATA.get(filename);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
const visibility = metadata.visibility || 'shareable';
// Check permissions
if (visibility === 'private' || visibility === 'clip_shareable') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('This video is not available', {
status: 403,
headers: corsHeaders
});
}
}
// Get video object to check if it exists
const videoObject = await env.R2_BUCKET.head(filename);
if (!videoObject) {
return new Response('Video not found', {
status: 404,
headers: corsHeaders
});
}
const sizeInMB = (videoObject.size / (1024 * 1024)).toFixed(2);
const uploadDate = new Date(videoObject.uploaded).toLocaleDateString();
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${filename}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #1a1a1a;
padding: 15px 30px;
border-bottom: 1px solid #2a2a2a;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 20px;
}
.back-button {
padding: 8px 16px;
background: #2a2a2a;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.back-button:hover { background: #3a3a3a; }
h1 {
font-size: 1.2rem;
word-break: break-word;
flex: 1;
}
.video-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: #000;
}
video {
max-width: 100%;
max-height: calc(100vh - 200px);
width: 100%;
border-radius: 8px;
}
.info-section {
background: #1a1a1a;
padding: 20px 30px;
border-top: 1px solid #2a2a2a;
}
.info-content {
max-width: 1400px;
margin: 0 auto;
}
.info-row {
display: flex;
gap: 30px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.info-item {
display: flex;
gap: 8px;
}
.info-label {
color: #aaa;
font-size: 0.9rem;
}
.info-value {
color: #fff;
font-size: 0.9rem;
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
button, .button {
padding: 10px 20px;
background: #3ea6ff;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
display: inline-block;
}
button:hover, .button:hover { background: #2988d8; }
@media (max-width: 768px) {
header { padding: 10px 15px; }
h1 { font-size: 1rem; }
.info-section { padding: 15px; }
video { max-height: 50vh; }
}
</style>
</head>
<body>
<header>
<div class="header-content">
<a href="/" class="back-button">← Gallery</a>
<h1>${filename}</h1>
</div>
</header>
<div class="video-container">
<video controls autoplay preload="auto">
<source src="/${filename}" type="${getContentType(filename)}">
Your browser does not support the video tag.
</video>
</div>
<div class="info-section">
<div class="info-content">
<div class="info-row">
<div class="info-item">
<span class="info-label">Size:</span>
<span class="info-value">${sizeInMB} MB</span>
</div>
<div class="info-item">
<span class="info-label">Uploaded:</span>
<span class="info-value">${uploadDate}</span>
</div>
<div class="info-item">
<span class="info-label">Format:</span>
<span class="info-value">${filename.split('.').pop().toUpperCase()}</span>
</div>
</div>
<div class="action-buttons">
<button onclick="copyLink()">📋 Copy Link</button>
<a href="/${filename}" download class="button">⬇ Download</a>
</div>
</div>
</div>
<script>
function copyLink() {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
alert('Link copied to clipboard!');
});
}
</script>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders }
});
}
/**
* Get content type
*/
function getContentType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const types = {
'mp4': 'video/mp4',
'mkv': 'video/x-matroska',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
'webm': 'video/webm',
'flv': 'video/x-flv',
'wmv': 'video/x-ms-wmv',
'm3u8': 'application/vnd.apple.mpegurl',
'ts': 'video/MP2T',
};
return types[ext] || 'application/octet-stream';
}