initial commit
This commit is contained in:
152
.gitignore
vendored
Normal file
152
.gitignore
vendored
Normal 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
231
App.tsx
Normal 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>© {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
14
README.md
Normal 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`
|
60
components/BorderlessToolbar.tsx
Normal file
60
components/BorderlessToolbar.tsx
Normal 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
200
components/VideoCell.tsx
Normal 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
40
index.html
Normal 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
15
index.tsx
Normal 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
5
metadata.json
Normal 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
1072
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
30
tsconfig.json
Normal 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
35
utils/youtube.ts
Normal 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
73
utils/youtubeApiLoader.ts
Normal 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
17
vite.config.ts
Normal 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
69
youtube.d.ts
vendored
Normal 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 {};
|
Reference in New Issue
Block a user