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