initial commit

This commit is contained in:
2025-06-14 18:25:25 -04:00
commit 22a6ef2d33
15 changed files with 2033 additions and 0 deletions

152
.gitignore vendored Normal file
View File

@ -0,0 +1,152 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

231
App.tsx Normal file
View File

@ -0,0 +1,231 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { VideoCell } from './components/VideoCell';
import { BorderlessToolbar } from './components/BorderlessToolbar';
import { getYouTubeEmbedUrl } from './utils/youtube';
import { loadYouTubeIframeApi } from './utils/youtubeApiLoader';
const initialVideoUrls = ['', '', '', '']; // Up to 4 videos
const App: React.FC = () => {
const [videoUrls, setVideoUrls] = useState<string[]>(initialVideoUrls);
const [isBorderlessModeActive, setIsBorderlessModeActive] = useState<boolean>(false);
const [activeAudioVideoId, setActiveAudioVideoId] = useState<string | null>(null);
const [isYtApiReady, setIsYtApiReady] = useState<boolean>(false);
const [appErrorMessage, setAppErrorMessage] = useState<string | null>(null);
const [toolbarHeight, setToolbarHeight] = useState<number>(0);
const playerRefs = useRef<{ [key: string]: YT.Player | null }>({});
const toolbarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadYouTubeIframeApi()
.then(() => {
setIsYtApiReady(true);
setAppErrorMessage(null);
})
.catch(error => {
console.error("Failed to load YouTube API:", error);
setAppErrorMessage("Error: Could not load YouTube player API. Video playback may not work correctly. Please try refreshing the page.");
setIsYtApiReady(false);
});
}, []);
const handleUrlChange = useCallback((index: number, newUrl: string) => {
setVideoUrls(prevUrls => {
const updatedUrls = [...prevUrls];
updatedUrls[index] = newUrl;
return updatedUrls;
});
if (!newUrl && activeAudioVideoId === `video-${index}`) {
setActiveAudioVideoId(null);
}
}, [activeAudioVideoId]);
const setPlayerInstance = useCallback((cellId: string, player: YT.Player | null) => {
playerRefs.current[cellId] = player;
}, []);
const handleSelectAudio = useCallback((cellId: string) => {
setActiveAudioVideoId(cellId);
Object.keys(playerRefs.current).forEach(key => {
const player = playerRefs.current[key];
if (player && typeof player.isMuted === 'function') {
if (key === cellId) {
console.log(`Unmuting player for cell: ${cellId}`);
if (player.isMuted()) player.unMute();
player.setVolume(100); // Ensure volume is set to audible level
} else {
console.log(`Muting player for cell: ${key}`);
player.mute(); // Always mute without checking current state
}
}
});
}, []);
const toggleBorderlessMode = () => {
setIsBorderlessModeActive(prev => {
const newMode = !prev;
if (!newMode) { // Exiting borderless mode
setActiveAudioVideoId(null);
Object.values(playerRefs.current).forEach(player => {
if (player && typeof player.isMuted === 'function' && !player.isMuted()) {
player.mute();
}
});
}
return newMode;
});
};
useEffect(() => {
const calculateToolbarHeight = () => {
if (isBorderlessModeActive && toolbarRef.current) {
setToolbarHeight(toolbarRef.current.offsetHeight);
}
};
if (isBorderlessModeActive) {
document.body.style.overflow = 'hidden';
calculateToolbarHeight(); // Initial calculation
window.addEventListener('resize', calculateToolbarHeight);
} else {
document.body.style.overflow = '';
setToolbarHeight(0); // Reset when exiting
}
return () => {
document.body.style.overflow = '';
window.removeEventListener('resize', calculateToolbarHeight);
};
}, [isBorderlessModeActive]);
const activeVideoDetails = videoUrls
.map((url, index) => ({
url,
cellId: `video-${index}`,
cellNumber: index + 1,
originalIndex: index, // Keep original index
isValid: getYouTubeEmbedUrl(url) !== null,
}))
.filter(video => video.isValid);
let gridLayoutClasses = "grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4 w-full max-w-7xl";
let mainContainerStyles: React.CSSProperties = {};
if (isBorderlessModeActive) {
mainContainerStyles = {
position: 'fixed',
top: `${toolbarHeight}px`, // Offset by toolbar height
left: 0,
width: '100vw',
height: `calc(100vh - ${toolbarHeight}px)`, // Adjust height
zIndex: 40, // Below toolbar (z-50) but above default content
background: '#000',
};
switch (activeVideoDetails.length) {
case 1:
gridLayoutClasses = "grid grid-cols-1 grid-rows-1 h-full";
break;
case 2:
gridLayoutClasses = "grid grid-cols-2 grid-rows-1 h-full";
break;
case 3:
gridLayoutClasses = "grid grid-cols-3 grid-rows-1 h-full";
break;
case 4:
default:
gridLayoutClasses = "grid grid-cols-2 grid-rows-2 h-full";
break;
}
if (activeVideoDetails.length === 0) {
gridLayoutClasses = "flex items-center justify-center h-full";
}
}
return (
<div className={`min-h-screen flex flex-col items-center ${isBorderlessModeActive ? 'bg-black' : 'bg-gray-900 text-white p-4 md:p-6 lg:p-8'}`}>
{!isBorderlessModeActive && (
<header className="w-full max-w-7xl mb-6 text-center">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-red-500 mb-2">YouTube Redzone Viewer</h1>
<p className="text-gray-300 mb-6 text-sm sm:text-base">Load up to four YouTube videos. Enter Borderless Mode for immersive viewing!</p>
<button
onClick={toggleBorderlessMode}
disabled={!isYtApiReady && activeVideoDetails.length > 0}
className="px-5 py-2.5 sm:px-6 sm:py-3 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg shadow-md transition-all duration-150 ease-in-out disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-red-600 focus:outline-none focus:ring-4 focus:ring-red-400 focus:ring-opacity-50"
aria-label={isBorderlessModeActive ? 'Exit Borderless Mode' : 'Enter Borderless Mode'}
>
{isBorderlessModeActive ? 'Exit Borderless Mode' : 'Enter Borderless Mode'}
</button>
{(!isYtApiReady && activeVideoDetails.length > 0) && (
<p className="text-xs sm:text-sm text-yellow-300 mt-3 p-2 bg-yellow-800 bg-opacity-30 rounded-md">
YouTube Player API is not ready. Borderless mode might not function correctly.
</p>
)}
{appErrorMessage && (
<p role="alert" aria-live="assertive" className="text-xs sm:text-sm text-red-300 mt-3 p-2 bg-red-800 bg-opacity-40 rounded-md">{appErrorMessage}</p>
)}
</header>
)}
{isBorderlessModeActive && (
<BorderlessToolbar
ref={toolbarRef}
onExitBorderlessMode={toggleBorderlessMode}
activeVideos={activeVideoDetails.map(v => ({ cellId: v.cellId, cellNumber: v.cellNumber }))}
onSelectAudio={handleSelectAudio}
currentActiveAudioVideoId={activeAudioVideoId}
/>
)}
<main
id="video-grid-container"
className={`${gridLayoutClasses} ${isBorderlessModeActive ? '' : 'w-full max-w-7xl'}`}
style={mainContainerStyles}
aria-label="Video grid"
>
{isBorderlessModeActive ? (
activeVideoDetails.length > 0 ? (
activeVideoDetails.map(video => (
<VideoCell
key={video.cellId}
cellId={video.cellId}
initialUrl={video.url}
onUrlChange={(newUrl) => handleUrlChange(video.originalIndex, newUrl)}
cellNumber={video.cellNumber}
isBorderless={true}
isAudioActive={activeAudioVideoId === video.cellId}
setPlayerInstance={setPlayerInstance}
isApiReady={isYtApiReady}
/>
))
) : (
<div className="text-gray-400 text-lg">No active videos to display in borderless mode. Add some URLs!</div>
)
) : (
videoUrls.map((url, index) => (
<VideoCell
key={`video-cell-${index}`}
cellId={`video-${index}`}
initialUrl={url}
onUrlChange={(newUrl) => handleUrlChange(index, newUrl)}
cellNumber={index + 1}
isBorderless={false}
isAudioActive={activeAudioVideoId === `video-${index}`}
setPlayerInstance={setPlayerInstance}
isApiReady={isYtApiReady}
/>
))
)}
</main>
{!isBorderlessModeActive && (
<footer className="mt-10 sm:mt-12 text-center text-gray-500 text-xs sm:text-sm">
<p>&copy; {new Date().getFullYear()} YouTube Redzone Viewer. All video content is property of its respective owners.</p>
<p>Ensure you have the necessary rights to view and display the content.</p>
</footer>
)}
</div>
);
};
export default App;

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@ -0,0 +1,60 @@
import React from 'react';
interface ActiveVideoInfo {
cellId: string;
cellNumber: number;
}
interface BorderlessToolbarProps {
onExitBorderlessMode: () => void;
activeVideos: ActiveVideoInfo[];
onSelectAudio: (cellId: string) => void;
currentActiveAudioVideoId: string | null;
}
export const BorderlessToolbar = React.forwardRef<HTMLDivElement, BorderlessToolbarProps>(({
onExitBorderlessMode,
activeVideos,
onSelectAudio,
currentActiveAudioVideoId,
}, ref) => {
if (activeVideos.length === 0) return null; // Don't show toolbar if no videos are active
return (
<div
ref={ref}
className="fixed top-0 left-0 right-0 bg-black bg-opacity-75 p-2 sm:p-3 flex items-center justify-between space-x-2 sm:space-x-4 z-[100]"
role="toolbar"
aria-label="Borderless mode controls"
>
<button
onClick={onExitBorderlessMode}
className="px-3 py-1.5 sm:px-4 sm:py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-md text-xs sm:text-sm shadow-md transition-colors"
aria-label="Exit Borderless Mode"
>
Exit Borderless
</button>
<div className="flex items-center space-x-1 sm:space-x-2 overflow-x-auto">
<span className="text-gray-300 font-medium text-xs sm:text-sm mr-1 sm:mr-2 flex-shrink-0">Audio:</span>
{activeVideos.map(({ cellId, cellNumber }) => (
<button
key={cellId}
onClick={() => onSelectAudio(cellId)}
className={`px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors shadow flex-shrink-0
${currentActiveAudioVideoId === cellId
? 'bg-green-500 text-white hover:bg-green-600'
: 'bg-gray-600 text-gray-200 hover:bg-gray-500'}`}
aria-pressed={currentActiveAudioVideoId === cellId}
aria-label={`Play audio from video ${cellNumber}`}
>
V{cellNumber}
</button>
))}
</div>
</div>
);
});
BorderlessToolbar.displayName = 'BorderlessToolbar';

200
components/VideoCell.tsx Normal file
View File

@ -0,0 +1,200 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { getYouTubeEmbedUrl } from '../utils/youtube';
interface VideoCellProps {
cellId: string;
initialUrl: string;
onUrlChange: (newUrl: string) => void;
cellNumber: number;
isBorderless: boolean;
isAudioActive: boolean;
setPlayerInstance: (cellId: string, player: YT.Player | null) => void;
isApiReady: boolean;
}
export const VideoCell: React.FC<VideoCellProps> = ({
cellId,
initialUrl,
onUrlChange,
cellNumber,
isBorderless,
isAudioActive,
setPlayerInstance,
isApiReady,
}) => {
const [inputValue, setInputValue] = useState<string>(initialUrl);
const playerRef = useRef<YT.Player | null>(null);
const iframeContainerRef = useRef<HTMLDivElement>(null);
const embedUrl = useMemo(() => getYouTubeEmbedUrl(initialUrl), [initialUrl]);
useEffect(() => {
if (initialUrl !== inputValue) {
setInputValue(initialUrl);
}
}, [initialUrl]);
const handleSubmit = () => {
if (inputValue !== initialUrl) {
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
setPlayerInstance(cellId, null);
}
onUrlChange(inputValue);
}
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSubmit();
event.currentTarget.blur();
}
};
useEffect(() => {
if (!isApiReady || !embedUrl || !window.YT || !iframeContainerRef.current) {
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
setPlayerInstance(cellId, null);
}
return;
}
const videoId = embedUrl.split('/embed/')[1]?.split('?')[0];
if (!videoId) return;
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
const playerElementId = `ytplayer-${cellId}`;
let playerHostDiv = document.getElementById(playerElementId);
if (!playerHostDiv && iframeContainerRef.current) {
playerHostDiv = document.createElement('div');
playerHostDiv.id = playerElementId;
iframeContainerRef.current.innerHTML = '';
iframeContainerRef.current.appendChild(playerHostDiv);
}
if (!playerHostDiv) return;
const player = new window.YT.Player(playerElementId, {
height: '100%', // Player fills its container (playerHostDiv which is appended to iframeContainerRef)
width: '100%', // Player fills its container
videoId: videoId,
playerVars: {
autoplay: 1,
mute: 1,
controls: isBorderless ? 0 : 1,
playsinline: 1,
modestbranding: 1,
rel: 0,
iv_load_policy: 3,
origin: window.location.origin,
},
events: {
onReady: (event) => {
playerRef.current = event.target;
setPlayerInstance(cellId, event.target);
if (isAudioActive) {
event.target.unMute();
} else {
event.target.mute();
}
},
onError: (event) => {
console.error(`YouTube Player Error for ${cellId}:`, event.data);
}
},
});
return () => {
if (playerRef.current) {
// Check if destroy method exists before calling, good practice with external APIs
if (typeof playerRef.current.destroy === 'function') {
playerRef.current.destroy();
}
playerRef.current = null;
setPlayerInstance(cellId, null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isApiReady, embedUrl, cellId, setPlayerInstance, isBorderless]); // isAudioActive removed as onReady handles initial mute based on it. Mute control is separate effect.
useEffect(() => {
if (playerRef.current && typeof playerRef.current.isMuted === 'function') {
if (isAudioActive) {
if (playerRef.current.isMuted()) {
playerRef.current.unMute();
}
} else {
if (!playerRef.current.isMuted()) {
playerRef.current.mute();
}
}
}
}, [isAudioActive, cellId]);
return (
<div
className={`flex flex-col bg-gray-800 ${isBorderless ? 'p-0 border-0 rounded-none' : 'p-2 sm:p-3 rounded-lg shadow-xl border border-gray-700 space-y-2'}`}
role="region"
aria-labelledby={`video-cell-title-${cellId}`}
>
{!isBorderless && (
<>
<h2 id={`video-cell-title-${cellId}`} className="sr-only">Video Cell {cellNumber}</h2>
<label htmlFor={`url-input-${cellId}`} className="sr-only">YouTube URL for Video {cellNumber}</label>
<div className="flex space-x-2 items-center">
<span
className="flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-red-600 flex items-center justify-center text-white font-bold text-sm sm:text-base"
aria-hidden="true"
>
{cellNumber}
</span>
<input
id={`url-input-${cellId}`}
type="url"
value={inputValue}
onChange={handleInputChange}
onBlur={handleSubmit}
onKeyDown={handleKeyDown}
placeholder="Paste YouTube URL & press Enter"
className="flex-grow p-2 sm:p-2.5 rounded bg-gray-700 text-white border border-gray-600 focus:ring-2 focus:ring-red-500 focus:border-red-500 outline-none placeholder-gray-400 text-sm sm:text-base"
/>
</div>
</>
)}
<div
ref={iframeContainerRef}
className={`bg-black ${isBorderless ? 'w-full h-full rounded-none' : 'aspect-video rounded overflow-hidden border border-gray-700 shadow-inner'}`}
>
{(!isApiReady && embedUrl) && <p className="text-white p-4 text-center">YouTube API loading...</p>}
{!embedUrl && !isBorderless && (
<div className="w-full h-full flex items-center justify-center text-gray-500 text-xs sm:text-sm p-4 text-center">
Paste a YouTube URL above and press Enter or click away.
</div>
)}
{embedUrl && !isApiReady && isBorderless && (
<div className="w-full h-full flex items-center justify-center text-gray-900 text-xs sm:text-sm p-4 text-center bg-gray-600">
Loading video...
</div>
)}
{(initialUrl && !embedUrl && !isBorderless) && (
<div className="w-full h-full flex items-center justify-center text-red-400 text-xs sm:text-sm p-4 text-center">
Invalid or unsupported YouTube URL.
</div>
)}
</div>
</div>
);
};

40
index.html Normal file
View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Redzone Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
/* For Webkit scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #4B5563; /* gray-600 */
border-radius: 4px;
}
::-webkit-scrollbar-track {
background: #1F2937; /* gray-800 */
}
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://esm.sh/react-dom@^19.1.0/",
"react/": "https://esm.sh/react@^19.1.0/",
"react": "https://esm.sh/react@^19.1.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "YouTube Redzone Viewer",
"description": "Watch up to four YouTube videos simultaneously in a 2x2 grid, with the ability to pop out the entire grid as a Picture-in-Picture window.",
"requestFramePermissions": []
}

1072
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "youtube-redzone-viewer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.1.0",
"react": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}

30
tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"allowJs": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*" : ["./*"]
}
}
}

35
utils/youtube.ts Normal file
View File

@ -0,0 +1,35 @@
/**
* Extracts a YouTube video ID from various URL formats.
* @param url The YouTube URL.
* @returns The video ID, or null if not found.
*/
const extractYouTubeVideoId = (url: string): string | null => {
if (!url || typeof url !== 'string') return null;
const regExp = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=|[^\/]+\/(?:live|shorts)\/)|youtu\.be\/)([^"&?\/\s]{11})/;
const match = url.match(regExp);
if (match && match[1]) {
return match[1];
}
return null;
};
/**
* Generates a YouTube embed URL with common parameters and API support.
* @param url The original YouTube URL.
* @returns The embed URL string, or null if the video ID cannot be extracted or URL is invalid.
*/
export const getYouTubeEmbedUrl = (url: string): string | null => {
const videoId = extractYouTubeVideoId(url);
if (!videoId) {
return null;
}
// Common parameters + enablejsapi for API control + origin for security
// mute=1 is important for allowing autoplay and programmatic unmuting.
// The origin parameter MUST match the domain where the iframe is hosted.
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const params = `autoplay=1&mute=1&playsinline=1&controls=1&modestbranding=1&rel=0&fs=1&iv_load_policy=3&enablejsapi=1&origin=${encodeURIComponent(origin)}`;
return `https://www.youtube.com/embed/${videoId}?${params}`;
};

73
utils/youtubeApiLoader.ts Normal file
View File

@ -0,0 +1,73 @@
export const loadYouTubeIframeApi = (): Promise<typeof window.YT> => {
return new Promise((resolve, reject) => {
if (window.YT && window.YT.Player) {
resolve(window.YT);
return;
}
const existingScript = document.getElementById('youtube-iframe-api');
const checkReady = () => {
if (window.YT && window.YT.Player) {
resolve(window.YT);
} else {
// API script might be loaded but YT object not yet initialized
// This can happen if onYouTubeIframeAPIReady hasn't fired or was missed.
// We poll briefly.
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (window.YT && window.YT.Player) {
clearInterval(interval);
resolve(window.YT);
} else if (attempts > 20) { // Try for ~2 seconds
clearInterval(interval);
reject(new Error('YouTube Iframe API loaded but YT object not found after timeout.'));
}
}, 100);
}
};
if (existingScript) {
// If script tag exists, it might be loading or loaded.
if (window.YT && window.YT.Player) {
resolve(window.YT);
return;
}
// Cast to access readyState, which might not be in default TS lib HTMLScriptElement
const scriptElement = existingScript as HTMLScriptElement & { readyState?: string };
if (scriptElement.readyState === 'loaded' || scriptElement.readyState === 'complete') {
checkReady();
} else {
existingScript.addEventListener('load', checkReady);
existingScript.addEventListener('error', () => reject(new Error('Failed to load existing YouTube API script.')));
}
return;
}
const tag = document.createElement('script');
tag.id = 'youtube-iframe-api';
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
if (firstScriptTag && firstScriptTag.parentNode) {
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
} else {
// Fallback if no script tags exist (unlikely for a working app)
document.head.appendChild(tag);
}
(window as any).onYouTubeIframeAPIReady = () => {
if (window.YT && window.YT.Player) {
resolve(window.YT);
} else {
// This case should ideally not happen if API is working correctly
reject(new Error('onYouTubeIframeAPIReady fired but YT.Player not found.'));
}
};
tag.addEventListener('error', (e) => {
// Use a more generic error message or inspect 'e' for details if needed
reject(new Error('Failed to load YouTube Iframe API script.'));
});
});
};

17
vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

69
youtube.d.ts vendored Normal file
View File

@ -0,0 +1,69 @@
// A type for the global YT object and its Player constructor
declare global {
interface Window {
YT?: {
Player: new (frameId: string | HTMLDivElement, options: YT.PlayerOptions) => YT.Player;
// Add other YT properties if needed, like enums for player states
PlayerState?: {
ENDED: number;
PLAYING: number;
PAUSED: number;
BUFFERING: number;
CUED: number;
};
};
onYouTubeIframeAPIReady?: () => void;
}
}
// Minimal YT.Player and YT.PlayerOptions types (can be expanded)
declare namespace YT {
interface Player {
playVideo: () => void;
pauseVideo: () => void;
stopVideo: () => void;
mute: () => void;
unMute: () => void;
isMuted: () => boolean;
getVolume: () => number;
setVolume: (volume: number) => void;
getPlayerState: () => number;
addEventListener: <E extends keyof Events>(event: E, listener: Events[E]) => void;
destroy: () => void;
// Add more methods as needed
getIframe: () => HTMLIFrameElement;
getVideoUrl: () => string;
loadVideoById: (videoId: string, startSeconds?: number, suggestedQuality?: string) => void;
cueVideoById: (videoId: string, startSeconds?: number, suggestedQuality?: string) => void;
}
interface PlayerOptions {
height?: string;
width?: string;
videoId?: string;
playerVars?: PlayerVars;
events?: Events;
}
interface PlayerVars {
autoplay?: 0 | 1;
controls?: 0 | 1 | 2;
enablejsapi?: 0 | 1;
origin?: string;
rel?: 0 | 1;
showinfo?: 0 | 1;
// Add more playerVars as needed
[key: string]: any; // Allow other player vars
}
interface Events {
onReady?: (event: { target: Player }) => void;
onStateChange?: (event: { data: number; target: Player }) => void;
onError?: (event: { data: number; target: Player }) => void;
// Add more events as needed
}
}
// Add this export to make the file a module
export {};