initial commit
This commit is contained in:
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>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user