diff --git a/public/reel.mp4 b/public/reel.mp4 deleted file mode 100644 index 41a5f8f..0000000 Binary files a/public/reel.mp4 and /dev/null differ diff --git a/src/app/api/media/[...path]/route.ts b/src/app/api/media/[...path]/route.ts new file mode 100644 index 0000000..abb626b --- /dev/null +++ b/src/app/api/media/[...path]/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + try { + const path = params.path.join('/'); + + // @ts-ignore - MEDIA is bound via wrangler.toml and available in the Cloudflare context + const cloudflareContext = (globalThis as any)[Symbol.for('__cloudflare-context__')]; + const MEDIA = cloudflareContext?.env?.MEDIA; + + if (!MEDIA) { + return NextResponse.json( + { error: 'Media bucket not configured' }, + { status: 500 } + ); + } + + // Get the object from R2 + const object = await MEDIA.get(path); + + if (!object) { + return NextResponse.json( + { error: `File not found: ${path}` }, + { status: 404 } + ); + } + + // Get range header for video streaming support + const range = request.headers.get('range'); + + // Get the full object body to handle range requests properly + const body = await object.body.arrayBuffer(); + const totalLength = body.byteLength; + + let start = 0; + let end = totalLength - 1; + let status = 200; + + if (range) { + // Extract start and end positions from range header + const match = range.match(/bytes=(\d+)-(\d+)?/); + if (match) { + start = parseInt(match[1], 10); + end = match[2] ? parseInt(match[2], 10) : end; + + // Ensure end is within the bounds + if (end >= totalLength) { + end = totalLength - 1; + } + + status = 206; // Partial content + } + } + + // Calculate the length for the response + const contentLength = end - start + 1; + const slicedBody = body.slice(start, end + 1); + + // Set headers for the response + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('cache-control', 'public, max-age=31536000, immutable'); + + // Add CORS headers to allow video requests from the same origin + headers.set('access-control-allow-origin', '*'); + headers.set('access-control-allow-headers', 'range, content-type, accept'); + headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS'); + headers.set('access-control-expose-headers', 'content-range, accept-ranges, content-length, content-encoding'); + + // Add range response headers if needed + if (range) { + headers.set('content-range', `bytes ${start}-${end}/${totalLength}`); + headers.set('accept-ranges', 'bytes'); + } + + headers.set('content-length', contentLength.toString()); + headers.set('content-type', path.endsWith('.mp4') ? 'video/mp4' : object.httpMetadata?.contentType || 'application/octet-stream'); + + return new NextResponse(slicedBody, { + status, + headers, + }); + } catch (error) { + console.error('Error serving media:', error); + return NextResponse.json( + { error: 'Failed to serve media file' }, + { status: 500 } + ); + } +}