|
/** |
|
* Video URL parsing and validation utilities |
|
*/ |
|
|
|
// Video type detection patterns |
|
const YOUTUBE_PATTERNS = [ |
|
// Covers: https://www.youtube.com/watch?v=, https://youtu.be/, https://www.youtube.com/embed/, |
|
// and even some older formats like https://www.youtube.com/v/ |
|
/(?:youtube\.com\/(?:watch\?v=|embed\/|v\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/, |
|
// Covers URLs with v= parameter potentially anywhere after watch? |
|
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/, |
|
]; |
|
|
|
const VIMEO_PATTERNS = [ |
|
// Covers: https://vimeo.com/, https://player.vimeo.com/video/ |
|
/(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/, |
|
// Covers group videos: https://vimeo.com/groups/ |
|
/vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/, |
|
]; |
|
|
|
// Video type enum |
|
export type VideoType = 'html5' | 'youtube' | 'vimeo'; |
|
|
|
// Video URL parser result |
|
export type VideoInfo = |
|
| { type: 'html5'; id?: undefined; url: string } // id is undefined for html5 |
|
| { type: 'youtube'; id: string; url: string } // id is required for youtube |
|
| { type: 'vimeo'; id: string; url: string }; // id is required for vimeo |
|
|
|
/** |
|
* Parse a video URL to determine its type and extract relevant information |
|
*/ |
|
export const parseVideoUrl = (url: string): VideoInfo => { |
|
// Check for YouTube |
|
for (const pattern of YOUTUBE_PATTERNS) { |
|
const match = url.match(pattern); |
|
if (match) { |
|
return { id: match[1], type: 'youtube', url }; // TypeScript infers this as { type: 'youtube', id: string, url: string } |
|
} |
|
} |
|
|
|
// Check for Vimeo |
|
for (const pattern of VIMEO_PATTERNS) { |
|
const match = url.match(pattern); |
|
if (match) { |
|
return { id: match[1], type: 'vimeo', url }; // TypeScript infers this as { type: 'vimeo', id: string, url: string } |
|
} |
|
} |
|
|
|
// Default to HTML5 |
|
return { type: 'html5', url }; // TypeScript infers this as { type: 'html5', id?: undefined, url: string } |
|
}; |
|
|
|
/** |
|
* Check if a URL is a valid video URL |
|
*/ |
|
export const isValidVideoUrl = (url: string): boolean => { |
|
if (!url) return false; |
|
|
|
const videoInfo = parseVideoUrl(url); |
|
|
|
// For HTML5 videos, check if it's a valid video file extension |
|
if (videoInfo.type === 'html5') { |
|
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']; |
|
const lowerUrl = url.toLowerCase(); |
|
// Check if the URL string itself ends with a valid video extension, |
|
// or if it contains a common video file pattern before query parameters. |
|
return ( |
|
videoExtensions.some(ext => lowerUrl.endsWith(ext)) || |
|
lowerUrl.includes('.mp4?') || |
|
lowerUrl.includes('.webm?') || |
|
lowerUrl.includes('.ogg?') |
|
); |
|
} |
|
|
|
// For YouTube/Vimeo, it's valid if an ID was successfully extracted. |
|
return !!videoInfo.id; |
|
}; |
|
|
|
/** |
|
* Get the appropriate poster/thumbnail URL for a video |
|
*/ |
|
export const getVideoThumbnail = (url: string): string | null => { |
|
const videoInfo = parseVideoUrl(url); |
|
|
|
switch (videoInfo.type) { |
|
case 'youtube': |
|
if (videoInfo.id) { |
|
// Prefer maxresdefault, then fallback to hqdefault (high quality), then default (always exists) |
|
// Using HTTPS for security on thumbnail URLs |
|
const hq = `https://img.youtube.com/vi/${videoInfo.id}/hqdefault.jpg`; |
|
const maxRes = `https://img.youtube.com/vi/${videoInfo.id}/maxresdefault.jpg`; |
|
const standard = `https://img.youtube.com/vi/${videoInfo.id}/default.jpg`; |
|
|
|
// This attempts maxres, then hq, then standard using JS short-circuiting. |
|
return maxRes || hq || standard; |
|
} |
|
return null; |
|
|
|
case 'vimeo': |
|
// Vimeo requires an API call for thumbnails, which is outside the scope of simple URL parsing. |
|
// You would typically use their oEmbed endpoint (e.g., https://vimeo.com/api/oembed.json?url=...). |
|
// Returning null as per our original implementation for simplicity. |
|
return null; |
|
|
|
case 'html5': |
|
// HTML5 videos usually need a 'poster' attribute set directly on the <video> tag, |
|
// or you might provide a default image if none is specified. |
|
return null; |
|
default: |
|
return null; |
|
} |
|
}; |
|
|
|
// --- YouTube API Types --- |
|
interface YouTubePlayer { |
|
addEventListener(event: 'onReady' | 'onError', listener: (event: YouTubePlayerEvent) => void): void; |
|
destroy(): void; |
|
getDuration(): number; |
|
// Add other methods/properties if used in other parts of the code |
|
// getPlayerState(): number; |
|
// pauseVideo(): void; |
|
// playVideo(): void; |
|
} |
|
|
|
interface YouTubePlayerEvent { |
|
data: number; // For onReady, data is usually undefined. For onError, it's the error code. |
|
target: YouTubePlayer; |
|
} |
|
|
|
interface YouTubeAPI { |
|
Player: new (elementId: string | HTMLElement, options: YouTubePlayerOptions) => YouTubePlayer; |
|
// PlayerState and Error enums are available but not directly used in this duration logic |
|
// Error: { HTML5_ERROR: 5; INVALID_PARAM: 2; UNAUTHORIZED_EMBEDDING: 150; VIDEO_NOT_EMBEDDABLE: 101; VIDEO_NOT_FOUND: 100; }; // Alphabetized |
|
// PlayerState: { BUFFERING: 3; CUED: 5; ENDED: 0; PAUSED: 2; PLAYING: 1; UNSTARTED: -1; }; // Alphabetized |
|
} |
|
|
|
interface YouTubePlayerOptions { |
|
events?: { |
|
onError?: (event: YouTubePlayerEvent) => void; |
|
onReady?: (event: YouTubePlayerEvent) => void; |
|
}; |
|
playerVars?: { [key: string]: number | string }; |
|
videoId: string; |
|
} |
|
|
|
// Declare global YT object for TypeScript |
|
declare global { |
|
interface Window { |
|
YT: YouTubeAPI; |
|
onYouTubeIframeAPIReady: (() => void) | undefined; |
|
} |
|
} |
|
|
|
// A promise that resolves when the YouTube IFrame API is ready |
|
let youtubeApiReadyPromise: Promise<void> | null = null; |
|
|
|
const loadYouTubeIframeAPI = (): Promise<void> => { |
|
if (youtubeApiReadyPromise) { |
|
return youtubeApiReadyPromise; |
|
} |
|
|
|
youtubeApiReadyPromise = new Promise(resolve => { |
|
// Check if API is already loaded |
|
if (typeof window.YT !== 'undefined' && typeof window.YT.Player !== 'undefined') { |
|
resolve(); |
|
return; |
|
} |
|
|
|
// Create script tag to load YouTube IFrame API |
|
const firstScriptTag = document.getElementsByTagName('script')[0]; |
|
const tag = document.createElement('script'); |
|
tag.src = 'https://www.youtube.com/iframe_api'; |
|
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); |
|
|
|
// This global function is called by the YouTube API script when it loads |
|
const originalOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady; |
|
window.onYouTubeIframeAPIReady = () => { |
|
if (originalOnYouTubeIframeAPIReady) { |
|
originalOnYouTubeIframeAPIReady(); // Call any existing handler |
|
} |
|
resolve(); // Resolve our promise once API is ready |
|
// Restore original handler to prevent interference with other YT API users |
|
window.onYouTubeIframeAPIReady = originalOnYouTubeIframeAPIReady; |
|
}; |
|
}); |
|
|
|
return youtubeApiReadyPromise; |
|
}; |
|
|
|
// --- Vimeo API Types --- |
|
interface VimeoMessageData { |
|
event?: 'ready'; // Specific event for Vimeo API readiness |
|
method?: string; // e.g., 'getDuration', 'addEventListener' |
|
player_id?: string; |
|
value?: number; // Explicitly type 'value' as number for getDuration response |
|
} |
|
|
|
/** |
|
* 🎥 Retrieves video duration from a URL |
|
* @param url - Video URL |
|
* @returns Promise resolving to object containing duration in seconds |
|
*/ |
|
export const getVideoDuration = (url: string): Promise<{ duration: number }> => { |
|
const videoInfo = parseVideoUrl(url); |
|
|
|
return new Promise(resolve => { |
|
if (videoInfo.type === 'youtube') { |
|
loadYouTubeIframeAPI() |
|
.then(() => { |
|
const videoId = videoInfo.id; |
|
const tempDivId = `yt-duration-div-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; |
|
const tempDiv = document.createElement('div'); // YT.Player needs a div to replace |
|
tempDiv.id = tempDivId; |
|
tempDiv.style.display = 'none'; // Keep it hidden |
|
document.body.appendChild(tempDiv); |
|
|
|
const cleanup = () => { |
|
if (typeof player.destroy === 'function') { |
|
player.destroy(); // Clean up YouTube player instance |
|
} |
|
if (tempDiv.parentNode) { |
|
tempDiv.parentNode.removeChild(tempDiv); // Remove the div from DOM |
|
} |
|
}; |
|
|
|
const player = new window.YT.Player(tempDivId, { |
|
events: { |
|
onError: (event: YouTubePlayerEvent) => { |
|
// Typed event |
|
console.error('YouTube Player API error getting duration:', event.data, 'for video ID:', videoId); |
|
resolve({ duration: 0 }); |
|
cleanup(); |
|
}, |
|
onReady: (event: YouTubePlayerEvent) => { |
|
// Typed event |
|
const duration = event.target.getDuration(); // Get duration from player instance |
|
resolve({ duration }); |
|
cleanup(); |
|
}, |
|
}, |
|
playerVars: { |
|
autoplay: 0, |
|
controls: 0, |
|
disablekb: 1, |
|
enablejsapi: 1, |
|
fs: 0, |
|
iv_load_policy: 3, |
|
modestbranding: 1, |
|
origin: window.location.origin, |
|
rel: 0, |
|
showinfo: 0, |
|
}, |
|
videoId: videoId, |
|
}); |
|
|
|
player.addEventListener('onReady', () => clearTimeout(timeout)); |
|
|
|
// Add a timeout for YouTube player creation/ready state |
|
const timeout = setTimeout(() => { |
|
// If the timeout fires, it means onReady or onError didn't resolve the promise in time. |
|
console.error('YouTube duration request timed out or player failed to initialize for video ID:', videoId); |
|
resolve({ duration: 0 }); |
|
cleanup(); // Ensure cleanup even on timeout |
|
}, 10000); // 10 seconds timeout |
|
}) |
|
.catch(error => { |
|
console.error('Failed to load YouTube IFrame API:', error); |
|
resolve({ duration: 0 }); |
|
}); |
|
} else if (videoInfo.type === 'vimeo') { |
|
const iframe = document.createElement('iframe'); |
|
iframe.style.display = 'none'; |
|
// Use the buildVimeoUrl to ensure all necessary API parameters are included |
|
iframe.src = buildVimeoUrl(videoInfo.id, { api: true, autoplay: false }); |
|
document.body.appendChild(iframe); |
|
|
|
const handleVimeoMessage = (event: MessageEvent) => { |
|
// Ensure message is from the correct origin and source iframe |
|
if (event.origin !== 'https://player.vimeo.com' || event.source !== iframe.contentWindow) { |
|
return; |
|
} |
|
|
|
const data: VimeoMessageData = JSON.parse(event.data); // Typed data |
|
|
|
if (data.event === 'ready') { |
|
// Once ready, request the duration |
|
iframe.contentWindow?.postMessage(JSON.stringify({ method: 'getDuration' }), 'https://player.vimeo.com'); |
|
} else if (data.method === 'getDuration') { |
|
resolve({ duration: data.value as number }); // Cast data.value to number |
|
window.removeEventListener('message', handleVimeoMessage); |
|
iframe.remove(); // Clean up iframe |
|
clearTimeout(timeout); // Clear the timeout on success |
|
} |
|
}; |
|
|
|
window.addEventListener('message', handleVimeoMessage); |
|
|
|
// Add a timeout for Vimeo player ready state and duration response |
|
const timeout = setTimeout(() => { |
|
console.error('Vimeo duration request timed out for video ID:', videoInfo.id); |
|
window.removeEventListener('message', handleVimeoMessage); |
|
iframe.remove(); |
|
resolve({ duration: 0 }); |
|
}, 10000); // 10 seconds timeout |
|
} else { |
|
// no need to check for html5 here as its the only other option |
|
const video = document.createElement('video'); |
|
video.preload = 'metadata'; // Only download enough to get metadata |
|
|
|
const cleanup = () => { |
|
video.onerror = null; |
|
video.onloadedmetadata = null; // Remove event listeners |
|
video.remove(); // Remove the temporary video element from DOM |
|
}; |
|
|
|
video.onloadedmetadata = () => { |
|
window.URL.revokeObjectURL(video.src); // Clean up object URL |
|
resolve({ duration: video.duration }); |
|
cleanup(); |
|
}; |
|
|
|
video.onerror = () => { |
|
console.error('Failed to load HTML5 video duration for URL:', url); |
|
resolve({ duration: 0 }); |
|
cleanup(); |
|
}; |
|
|
|
video.src = url; |
|
} |
|
}); |
|
}; |
|
|
|
// Helper to format video duration to MM:SS:MM (minutes:seconds:centiseconds) |
|
export const formatVideoDuration = (duration: number) => { |
|
const minutes = Math.floor(duration / 60) |
|
.toString() |
|
.padStart(2, '0'); |
|
const seconds = Math.floor(duration % 60) |
|
.toString() |
|
.padStart(2, '0'); |
|
const centiseconds = Math.floor((duration % 1) * 100) |
|
.toString() |
|
.padStart(2, '0'); |
|
|
|
return `${minutes}:${seconds}:${centiseconds}`; |
|
}; |
|
|
|
/** |
|
* Build YouTube embed URL with parameters |
|
*/ |
|
export const buildYouTubeUrl = ( |
|
videoId: string, |
|
options: { |
|
autoplay?: boolean; |
|
modestbranding?: boolean; |
|
origin?: string; |
|
rel?: boolean; |
|
// Add other common params if needed, e.g., controls, loop, playlist |
|
} = {} |
|
): string => { |
|
const params = new URLSearchParams({ |
|
autoplay: options.autoplay ? '1' : '0', |
|
enablejsapi: '1', // Essential for postMessage API |
|
modestbranding: options.modestbranding !== false ? '1' : '0', // Default to true |
|
rel: options.rel === false ? '0' : '1', // Default to true (show related videos) |
|
}); |
|
|
|
// 'origin' is crucial for security with enablejsapi, should be your app's origin |
|
if (options.origin) { |
|
params.append('origin', options.origin); |
|
} else { |
|
// Fallback to window.location.origin if not provided, for robustness |
|
// This is a client-side utility, so window.location is safe to access. |
|
params.append('origin', window.location.origin); |
|
} |
|
|
|
// Correct base URL for YouTube embeds, using HTTPS or protocol-relative |
|
// https://www.youtube.com/embed/${videoId} is the standard embed URL. |
|
// Using 'youtube-nocookie.com' is also an option for privacy-enhanced mode. |
|
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`; |
|
}; |
|
|
|
/** |
|
* Build Vimeo embed URL with parameters |
|
*/ |
|
export const buildVimeoUrl = ( |
|
videoId: string, |
|
options: { |
|
api?: boolean; |
|
autoplay?: boolean; |
|
byline?: boolean; |
|
dnt?: boolean; // Do Not Track |
|
portrait?: boolean; |
|
title?: boolean; |
|
} = {} |
|
): string => { |
|
const params = new URLSearchParams({ |
|
autoplay: options.autoplay ? '1' : '0', |
|
byline: options.byline === false ? '0' : '1', // Default to true |
|
dnt: options.dnt === true ? '1' : '0', // Default to false |
|
portrait: options.portrait === false ? '0' : '1', // Default to true |
|
title: options.title === false ? '0' : '1', // Default to true |
|
}); |
|
|
|
// Added: 'api=1' is crucial for postMessage communication with Vimeo |
|
if (options.api !== false) { |
|
// Default to true |
|
params.append('api', '1'); |
|
} |
|
|
|
// Correct base URL for Vimeo embeds, always HTTPS |
|
return `https://player.vimeo.com/video/${videoId}?${params.toString()}`; |
|
}; |