mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 01:46:08 +00:00
chore(init): scaffold OpenFrame — Next.js + Bun + shadcn
Initialize OpenFrame project with core scaffold and UI foundation. - Project scaffold: Next.js 16.1 (App Router) + Bun runtime - UI: shadcn/ui + TailwindCSS; landing, dashboard, project, and auth UIs - Video support: provider abstraction (YouTube first), video player page with timestamped comments and custom timeline - Auth & DB: NextAuth skeleton and Prisma schema (Postgres) included - UX: dark-mode toggle, full-width layouts, comments sidebar, video route moved to /watch/[videoId] - Dev: added YouTube iframe integration, custom controls, and TypeScript types - Next steps: DB migrations, API routes (CRUD), real data wiring, voice-recording & sharing
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
|
||||
|
||||
// Direct video URL patterns (for future self-hosted videos)
|
||||
const DIRECT_VIDEO_PATTERNS = [
|
||||
/\.(mp4|webm|ogg|mov)(\?.*)?$/i,
|
||||
];
|
||||
|
||||
export const directProvider: VideoProvider = {
|
||||
id: 'direct',
|
||||
name: 'Direct Upload',
|
||||
icon: 'Upload',
|
||||
|
||||
canHandle(url: string): boolean {
|
||||
// Check for common video extensions or our own domain
|
||||
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url));
|
||||
},
|
||||
|
||||
extractVideoId(url: string): string | null {
|
||||
// For direct uploads, the "videoId" is the full URL
|
||||
// In production, this would be a storage key/path
|
||||
if (this.canHandle(url)) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
|
||||
// For direct videos, we'll use HTML5 video player
|
||||
// The videoId IS the URL for direct uploads
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
|
||||
|
||||
const queryString = params.toString();
|
||||
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
|
||||
},
|
||||
|
||||
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
|
||||
// For direct uploads, thumbnail would be generated server-side
|
||||
// Return a placeholder for now
|
||||
return '/placeholder-video-thumbnail.png';
|
||||
},
|
||||
|
||||
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||
// For direct uploads, metadata would be stored in our database
|
||||
// This is a placeholder implementation
|
||||
const filename = videoId.split('/').pop() || 'Video';
|
||||
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
||||
|
||||
return {
|
||||
title: nameWithoutExt,
|
||||
thumbnailUrl: this.getThumbnailUrl(videoId),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
// Video Provider Registry - Central place to manage all video providers
|
||||
|
||||
import { youtubeProvider } from './youtube';
|
||||
import { vimeoProvider } from './vimeo';
|
||||
import { directProvider } from './direct';
|
||||
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
|
||||
|
||||
// Export types
|
||||
export * from './types';
|
||||
|
||||
// Registry of all available providers
|
||||
const providers: VideoProvider[] = [
|
||||
youtubeProvider,
|
||||
vimeoProvider,
|
||||
directProvider,
|
||||
];
|
||||
|
||||
// Provider lookup map for quick access
|
||||
const providerMap = new Map<string, VideoProvider>(
|
||||
providers.map(p => [p.id, p])
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect which provider can handle a given URL
|
||||
*/
|
||||
export function detectProvider(url: string): VideoProvider | null {
|
||||
for (const provider of providers) {
|
||||
if (provider.canHandle(url)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider by its ID
|
||||
*/
|
||||
export function getProvider(providerId: VideoProviderType): VideoProvider | null {
|
||||
return providerMap.get(providerId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers
|
||||
*/
|
||||
export function getAllProviders(): VideoProvider[] {
|
||||
return [...providers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a video URL and return a VideoSource object
|
||||
*/
|
||||
export function parseVideoUrl(url: string): VideoSource | null {
|
||||
const provider = detectProvider(url);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoId = provider.extractVideoId(url);
|
||||
|
||||
if (!videoId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: provider.id as VideoProviderType,
|
||||
videoId,
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch metadata for a video source
|
||||
*/
|
||||
export async function fetchVideoMetadata(source: VideoSource): Promise<VideoMetadata | null> {
|
||||
const provider = getProvider(source.providerId);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await provider.getMetadata(source.videoId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embed URL for a video source
|
||||
*/
|
||||
export function getEmbedUrl(source: VideoSource, options?: Parameters<VideoProvider['getEmbedUrl']>[1]): string | null {
|
||||
const provider = getProvider(source.providerId);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider.getEmbedUrl(source.videoId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail URL for a video source
|
||||
*/
|
||||
export function getThumbnailUrl(source: VideoSource, size?: Parameters<VideoProvider['getThumbnailUrl']>[1]): string | null {
|
||||
const provider = getProvider(source.providerId);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider.getThumbnailUrl(source.videoId, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid video URL from any supported provider
|
||||
*/
|
||||
export function isValidVideoUrl(url: string): boolean {
|
||||
return detectProvider(url) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider icon name for UI display
|
||||
*/
|
||||
export function getProviderIcon(providerId: VideoProviderType): string {
|
||||
const provider = getProvider(providerId);
|
||||
return provider?.icon ?? 'Video';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Video Provider Types - Future-proof abstraction for multiple video sources
|
||||
|
||||
export interface VideoMetadata {
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnailUrl: string;
|
||||
duration?: number; // in seconds
|
||||
author?: string;
|
||||
authorUrl?: string;
|
||||
uploadDate?: Date;
|
||||
}
|
||||
|
||||
export interface VideoProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // Lucide icon name
|
||||
|
||||
// URL handling
|
||||
canHandle(url: string): boolean;
|
||||
extractVideoId(url: string): string | null;
|
||||
|
||||
// Embed and display
|
||||
getEmbedUrl(videoId: string, options?: EmbedOptions): string;
|
||||
getThumbnailUrl(videoId: string, size?: ThumbnailSize): string;
|
||||
|
||||
// Metadata fetching
|
||||
getMetadata(videoId: string): Promise<VideoMetadata>;
|
||||
}
|
||||
|
||||
export interface EmbedOptions {
|
||||
autoplay?: boolean;
|
||||
startTime?: number; // in seconds
|
||||
controls?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
|
||||
|
||||
// Supported provider types - extend as we add more
|
||||
export type VideoProviderType = 'youtube' | 'vimeo' | 'direct';
|
||||
|
||||
// Video source stored in database
|
||||
export interface VideoSource {
|
||||
providerId: VideoProviderType;
|
||||
videoId: string;
|
||||
originalUrl: string;
|
||||
metadata?: VideoMetadata;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
|
||||
|
||||
// Vimeo URL patterns
|
||||
const VIMEO_PATTERNS = [
|
||||
/vimeo\.com\/(\d+)/,
|
||||
/vimeo\.com\/video\/(\d+)/,
|
||||
/player\.vimeo\.com\/video\/(\d+)/,
|
||||
/vimeo\.com\/channels\/\w+\/(\d+)/,
|
||||
/vimeo\.com\/groups\/\w+\/videos\/(\d+)/,
|
||||
];
|
||||
|
||||
export const vimeoProvider: VideoProvider = {
|
||||
id: 'vimeo',
|
||||
name: 'Vimeo',
|
||||
icon: 'Video', // Lucide doesn't have Vimeo icon
|
||||
|
||||
canHandle(url: string): boolean {
|
||||
return VIMEO_PATTERNS.some(pattern => pattern.test(url));
|
||||
},
|
||||
|
||||
extractVideoId(url: string): string | null {
|
||||
for (const pattern of VIMEO_PATTERNS) {
|
||||
const match = url.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.autoplay) params.set('autoplay', '1');
|
||||
if (options.startTime) params.set('t', `${Math.floor(options.startTime)}s`);
|
||||
if (options.loop) params.set('loop', '1');
|
||||
if (options.muted) params.set('muted', '1');
|
||||
|
||||
// Better UX options
|
||||
params.set('byline', '0');
|
||||
params.set('portrait', '0');
|
||||
params.set('title', '0');
|
||||
|
||||
const queryString = params.toString();
|
||||
return `https://player.vimeo.com/video/${videoId}${queryString ? `?${queryString}` : ''}`;
|
||||
},
|
||||
|
||||
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
|
||||
// Vimeo requires API call to get thumbnail, return placeholder
|
||||
// In production, cache these after fetching metadata
|
||||
const sizeMap: Record<ThumbnailSize, number> = {
|
||||
small: 200,
|
||||
medium: 400,
|
||||
large: 640,
|
||||
maxres: 1280,
|
||||
};
|
||||
|
||||
// This is a placeholder - actual thumbnail comes from metadata API
|
||||
return `https://vumbnail.com/${videoId}_${sizeMap[size]}.jpg`;
|
||||
},
|
||||
|
||||
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch video metadata');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
thumbnailUrl: data.thumbnail_url,
|
||||
duration: data.duration,
|
||||
author: data.author_name,
|
||||
authorUrl: data.author_url,
|
||||
uploadDate: data.upload_date ? new Date(data.upload_date) : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
title: 'Vimeo Video',
|
||||
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
|
||||
|
||||
// YouTube URL patterns
|
||||
const YOUTUBE_PATTERNS = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
|
||||
];
|
||||
|
||||
export const youtubeProvider: VideoProvider = {
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
icon: 'Youtube',
|
||||
|
||||
canHandle(url: string): boolean {
|
||||
return YOUTUBE_PATTERNS.some(pattern => pattern.test(url));
|
||||
},
|
||||
|
||||
extractVideoId(url: string): string | null {
|
||||
for (const pattern of YOUTUBE_PATTERNS) {
|
||||
const match = url.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Enable JS API for programmatic control
|
||||
params.set('enablejsapi', '1');
|
||||
params.set('origin', typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
if (options.autoplay) params.set('autoplay', '1');
|
||||
if (options.startTime) params.set('start', String(Math.floor(options.startTime)));
|
||||
if (options.controls === false) params.set('controls', '0');
|
||||
if (options.loop) params.set('loop', '1');
|
||||
if (options.muted) params.set('mute', '1');
|
||||
|
||||
// Better UX options
|
||||
params.set('rel', '0'); // Don't show related videos from other channels
|
||||
params.set('modestbranding', '1'); // Minimal YouTube branding
|
||||
|
||||
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
|
||||
},
|
||||
|
||||
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
|
||||
const sizeMap: Record<ThumbnailSize, string> = {
|
||||
small: 'default', // 120x90
|
||||
medium: 'mqdefault', // 320x180
|
||||
large: 'hqdefault', // 480x360
|
||||
maxres: 'maxresdefault', // 1280x720
|
||||
};
|
||||
|
||||
return `https://img.youtube.com/vi/${videoId}/${sizeMap[size]}.jpg`;
|
||||
},
|
||||
|
||||
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||
// Using oEmbed API - no API key required
|
||||
// For production, you might want to use YouTube Data API for more data
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch video metadata');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
thumbnailUrl: data.thumbnail_url,
|
||||
author: data.author_name,
|
||||
authorUrl: data.author_url,
|
||||
};
|
||||
} catch (error) {
|
||||
// Fallback with minimal data
|
||||
return {
|
||||
title: 'YouTube Video',
|
||||
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user