2025-02-21 22:47:50 -08:00
|
|
|
import React, { useState, KeyboardEvent } from 'react';
|
|
|
|
|
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
|
|
|
|
|
import { getInvidiousSearchURL, INVIDIOUS_BASE_URL } from '../config';
|
|
|
|
|
|
|
|
|
|
interface InvidiousVideoThumbnail {
|
|
|
|
|
quality: string;
|
|
|
|
|
url: string;
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InvidiousResult {
|
|
|
|
|
type: string;
|
|
|
|
|
title: string;
|
|
|
|
|
videoId: string;
|
|
|
|
|
author: string;
|
|
|
|
|
videoThumbnails?: InvidiousVideoThumbnail[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InvidiousSearchModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSelectVideo: (url: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ResultCell: React.FC<{ result: InvidiousResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
|
|
|
|
|
const thumbnailUrl = (result: InvidiousResult) => {
|
|
|
|
|
if (!result.videoThumbnails) return '/assets/placeholder.jpg';
|
|
|
|
|
|
|
|
|
|
const thumbnail = result.videoThumbnails.find(t => t.quality === 'medium');
|
|
|
|
|
return thumbnail ? `${INVIDIOUS_BASE_URL}${thumbnail.url}` : '/assets/placeholder.jpg';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex gap-4 bg-black/20 p-2 rounded-lg cursor-pointer hover:bg-black/30 transition-colors" onClick={onClick} {...props}>
|
|
|
|
|
<img src={thumbnailUrl(result)} alt={result.title} className="w-32 h-18 object-cover rounded" />
|
|
|
|
|
<div className="flex flex-col justify-center">
|
|
|
|
|
<h3 className="text-white font-semibold">{result.title}</h3>
|
|
|
|
|
<p className="text-white/60">{result.author}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onClose, onSelectVideo }) => {
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [results, setResults] = useState<InvidiousResult[]>([]);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleSearch = async () => {
|
|
|
|
|
if (!searchQuery.trim()) return;
|
|
|
|
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(getInvidiousSearchURL(searchQuery));
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
const videoResults = data.filter((item: InvidiousResult) => {
|
|
|
|
|
return item.type === 'video' || item.type === 'playlist'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setResults(videoResults);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to search:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const _onSelectVideo = (url: string) => {
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
setResults([]);
|
|
|
|
|
onSelectVideo(url);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!isOpen) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
|
|
|
<div className="bg-violet-900 w-full max-w-xl rounded-lg p-4 shadow-lg">
|
|
|
|
|
<div className="flex justify-between items-center mb-4">
|
|
|
|
|
<h2 className="text-white text-xl font-bold">Search YouTube (Invidious)</h2>
|
|
|
|
|
|
|
|
|
|
<button onClick={onClose} className="text-white/60 hover:text-white">
|
|
|
|
|
<FaTimes size={24} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 mb-4">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
autoFocus
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
placeholder="Search videos..."
|
|
|
|
|
className="p-2 rounded-lg border-2 border-violet-500 flex-grow bg-black/20 text-white"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSearch}
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500 disabled:opacity-50"
|
|
|
|
|
>
|
2025-02-21 22:55:36 -08:00
|
|
|
{isLoading ? <span className="animate-spin"><FaSpinner /></span> : <span><FaSearch /></span>}
|
2025-02-21 22:47:50 -08:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="max-h-[60vh] overflow-y-auto">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="text-white text-center py-12">Searching...</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
{results.map((result) => (
|
|
|
|
|
<ResultCell
|
|
|
|
|
key={result.videoId}
|
|
|
|
|
result={result}
|
|
|
|
|
onClick={() => _onSelectVideo(`https://www.youtube.com/watch?v=${result.videoId}`)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default InvidiousSearchModal;
|