mirror of
https://github.com/wahyd4/ai-projects.git
synced 2026-08-08 20:56:10 +10:00
add a landing page of player
This commit is contained in:
Generated
+61
@@ -17,6 +17,7 @@
|
||||
"hls.js": "^1.5.15",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -3448,6 +3449,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz",
|
||||
"integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
@@ -14474,6 +14483,36 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.26.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz",
|
||||
"integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.19.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.26.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz",
|
||||
"integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.19.2",
|
||||
"react-router": "6.26.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-scripts": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
||||
@@ -20060,6 +20099,11 @@
|
||||
"source-map": "^0.7.3"
|
||||
}
|
||||
},
|
||||
"@remix-run/router": {
|
||||
"version": "1.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz",
|
||||
"integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA=="
|
||||
},
|
||||
"@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
@@ -28008,6 +28052,23 @@
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A=="
|
||||
},
|
||||
"react-router": {
|
||||
"version": "6.26.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz",
|
||||
"integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.19.2"
|
||||
}
|
||||
},
|
||||
"react-router-dom": {
|
||||
"version": "6.26.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz",
|
||||
"integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==",
|
||||
"requires": {
|
||||
"@remix-run/router": "1.19.2",
|
||||
"react-router": "6.26.2"
|
||||
}
|
||||
},
|
||||
"react-scripts": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -11,9 +12,9 @@
|
||||
"hls.js": "^1.5.15",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"@heroicons/react": "^2.0.18"
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npx react-scripts start",
|
||||
|
||||
+21
-242
@@ -1,252 +1,31 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon, TrashIcon, PlayIcon } from '@heroicons/react/24/solid';
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Link } from 'react-router-dom';
|
||||
import LandingPage from './components/LandingPage';
|
||||
import MainApp from './components/MainApp'; // 确保这里的路径正确
|
||||
|
||||
function App() {
|
||||
const [customChannels, setCustomChannels] = useState([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [isUploadMode, setIsUploadMode] = useState(false);
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [editingChannel, setEditingChannel] = useState(null);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const storedChannels = JSON.parse(localStorage.getItem('userChannels')) || [];
|
||||
setCustomChannels(storedChannels);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const playUrl = urlParams.get('play');
|
||||
if (playUrl) {
|
||||
setSelectedChannel({ name: 'URL Playback', url: playUrl });
|
||||
setUrlInput(playUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChannelSelect = (channel) => {
|
||||
setSelectedChannel(channel);
|
||||
setIsUploadMode(false);
|
||||
setUrlInput(channel.url);
|
||||
const newUrl = `${window.location.pathname}?play=${encodeURIComponent(channel.url)}`;
|
||||
window.history.pushState({ url: channel.url }, '', newUrl);
|
||||
};
|
||||
|
||||
const handleUrlSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (urlInput) {
|
||||
setSelectedChannel({ name: 'Custom URL', url: urlInput });
|
||||
const newUrl = `${window.location.pathname}?play=${encodeURIComponent(urlInput)}`;
|
||||
window.history.pushState({ url: urlInput }, '', newUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonSubmit = () => {
|
||||
try {
|
||||
const json = JSON.parse(jsonInput);
|
||||
if (Array.isArray(json) && json.every(item => item.name && item.url)) {
|
||||
const newChannels = json.map((item, index) => ({
|
||||
id: `custom-${customChannels.length + index + 1}`,
|
||||
name: item.name,
|
||||
url: item.url
|
||||
}));
|
||||
const updatedChannels = [...customChannels, ...newChannels];
|
||||
setCustomChannels(updatedChannels);
|
||||
localStorage.setItem('userChannels', JSON.stringify(updatedChannels));
|
||||
setJsonInput('');
|
||||
setError(null);
|
||||
} else {
|
||||
setError('JSON 格式不正确。请确保文件包含 name 和 url 字段的对象数组。');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('无法解析 JSON。请检查格式。');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
|
||||
const toggleUploadMode = () => {
|
||||
setIsUploadMode(!isUploadMode);
|
||||
setSelectedChannel(null);
|
||||
setEditingChannel(null);
|
||||
};
|
||||
|
||||
const handleEditChannel = (channel) => {
|
||||
setEditingChannel(channel);
|
||||
};
|
||||
|
||||
const handleUpdateChannel = () => {
|
||||
const updatedChannels = customChannels.map(ch =>
|
||||
ch.id === editingChannel.id ? editingChannel : ch
|
||||
);
|
||||
setCustomChannels(updatedChannels);
|
||||
localStorage.setItem('userChannels', JSON.stringify(updatedChannels));
|
||||
setEditingChannel(null);
|
||||
};
|
||||
|
||||
const handleDeleteChannel = (channelId) => {
|
||||
const updatedChannels = customChannels.filter(ch => ch.id !== channelId);
|
||||
setCustomChannels(updatedChannels);
|
||||
localStorage.setItem('userChannels', JSON.stringify(updatedChannels));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* 侧边栏 */}
|
||||
<div className={`relative flex flex-col bg-white shadow-lg transition-all duration-300 ease-in-out ${isSidebarOpen ? 'w-64' : 'w-16'}`}>
|
||||
<div className="flex-grow overflow-y-auto p-4">
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isSidebarOpen ? '' : 'hidden'}`}>自定义频道</h2>
|
||||
<ul>
|
||||
{customChannels.map((channel) => (
|
||||
<li
|
||||
key={channel.id}
|
||||
className={`mb-2 p-2 cursor-pointer rounded ${
|
||||
selectedChannel && selectedChannel.url === channel.url
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'hover:bg-gray-100'
|
||||
} ${isSidebarOpen ? '' : 'text-center'}`}
|
||||
onClick={() => handleChannelSelect(channel)}
|
||||
>
|
||||
{isSidebarOpen ? channel.name : channel.name.charAt(0)}
|
||||
</li>
|
||||
))}
|
||||
<Router>
|
||||
<div className="flex flex-col h-screen">
|
||||
<nav className="bg-gray-800 text-white p-4 z-50">
|
||||
<ul className="flex space-x-4">
|
||||
<li>
|
||||
<Link to="/" className="hover:text-blue-300">Home</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/player" className="hover:text-blue-300">Main Player</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 输入字段和按钮 */}
|
||||
<div className={`p-4 border-t ${isSidebarOpen ? '' : 'hidden'}`}>
|
||||
<form onSubmit={handleUrlSubmit} className="flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder="输入或粘贴 M3U8 URL"
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full p-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<PlayIcon className="h-5 w-5 mr-2" />
|
||||
播放
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex-grow overflow-hidden">
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/player" element={<MainApp />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
<div className={`mt-auto p-4 ${isSidebarOpen ? '' : 'hidden'}`}>
|
||||
<button
|
||||
onClick={toggleUploadMode}
|
||||
className="w-full p-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{isUploadMode ? '返回播放器' : '管理自定义频道'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="absolute top-0 right-0 mt-4 mr-4 p-1 rounded-full bg-gray-200 hover:bg-gray-300 focus:outline-none"
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<ChevronLeftIcon className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
{error && <p className="text-red-500 mb-4">{error}</p>}
|
||||
{isUploadMode ? (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-semibold mb-4">管理自定义频道</h2>
|
||||
<p className="mb-4">
|
||||
请在下方文本框中粘贴包含频道信息的 JSON 内容。JSON 应包含一个对象数组,每个对象有两个键:
|
||||
<code className="bg-gray-200 px-1 rounded">name</code> (频道名称) 和
|
||||
<code className="bg-gray-200 px-1 rounded">url</code> (流媒体 URL)。
|
||||
</p>
|
||||
<pre className="bg-gray-100 p-4 rounded mb-4">
|
||||
{JSON.stringify([
|
||||
{ name: "示例频道 1", url: "http://example.com/stream1.m3u8" },
|
||||
{ name: "示例频道 2", url: "http://example.com/stream2.m3u8" }
|
||||
], null, 2)}
|
||||
</pre>
|
||||
<textarea
|
||||
className="w-full h-64 p-2 border rounded mb-4"
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder="在此粘贴 JSON 内容..."
|
||||
/>
|
||||
<button
|
||||
onClick={handleJsonSubmit}
|
||||
className="w-full p-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors mb-8"
|
||||
>
|
||||
添加频道
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-4">已上传的自定义频道</h3>
|
||||
<ul className="space-y-4">
|
||||
{customChannels.map((channel) => (
|
||||
<li key={channel.id} className="bg-white p-4 rounded shadow">
|
||||
{editingChannel && editingChannel.id === channel.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editingChannel.name}
|
||||
onChange={(e) => setEditingChannel({...editingChannel, name: e.target.value})}
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editingChannel.url}
|
||||
onChange={(e) => setEditingChannel({...editingChannel, url: e.target.value})}
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateChannel}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="font-semibold">{channel.name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">{channel.url}</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditChannel(channel)}
|
||||
className="p-2 bg-yellow-500 text-white rounded hover:bg-yellow-600 transition-colors"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteChannel(channel.id)}
|
||||
className="p-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-4xl mx-auto">
|
||||
{selectedChannel && (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold mb-4">{selectedChannel.name}</h2>
|
||||
<VideoPlayer streamUrl={selectedChannel.url} autoPlay={true} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import { PlayIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
const LandingPage = () => {
|
||||
const [streamUrl, setStreamUrl] = useState('');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 组件卸载时恢复滚动
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUrlSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (streamUrl) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('https://source.unsplash.com/random/1920x1080?landscape')" }}>
|
||||
<div className="absolute inset-0 bg-black opacity-60"></div>
|
||||
<div className="z-10 w-full max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
{isPlaying ? (
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<VideoPlayer streamUrl={streamUrl} autoPlay={true} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-w-16 aspect-h-9 bg-gray-800 rounded-lg shadow-lg flex items-center justify-center">
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleUrlSubmit} className="flex flex-col sm:flex-row items-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<input
|
||||
type="text"
|
||||
value={streamUrl}
|
||||
onChange={(e) => setStreamUrl(e.target.value)}
|
||||
placeholder="Enter M3U8 URL"
|
||||
className="w-full sm:flex-grow p-4 bg-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition duration-300"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition duration-300 flex items-center justify-center"
|
||||
>
|
||||
<PlayIcon className="h-6 w-6 mr-2" />
|
||||
Play
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon, TrashIcon, PlayIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
function MainApp() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [customChannels, setCustomChannels] = useState([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [isUploadMode, setIsUploadMode] = useState(false);
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [editingChannel, setEditingChannel] = useState(null);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://127.0.0.1:5000/api/channels');
|
||||
setChannels(response.data);
|
||||
|
||||
const storedChannels = JSON.parse(localStorage.getItem('userChannels')) || [];
|
||||
setCustomChannels(storedChannels);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const channelId = urlParams.get('channel');
|
||||
if (channelId) {
|
||||
const channel = [...response.data, ...storedChannels].find(c => c.id.toString() === channelId);
|
||||
if (channel) {
|
||||
setSelectedChannel(channel);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取频道列表时出错:', error);
|
||||
setError('无法获取频道列表。请检查后端服务器是否正在运行。');
|
||||
}
|
||||
};
|
||||
|
||||
fetchChannels();
|
||||
}, []);
|
||||
|
||||
const handleChannelSelect = (channel) => {
|
||||
setSelectedChannel(channel);
|
||||
setIsUploadMode(false);
|
||||
const newUrl = `${window.location.pathname}?channel=${channel.id}`;
|
||||
window.history.pushState({ channelId: channel.id }, '', newUrl);
|
||||
};
|
||||
|
||||
const handleJsonSubmit = () => {
|
||||
try {
|
||||
const json = JSON.parse(jsonInput);
|
||||
if (Array.isArray(json) && json.every(item => item.name && item.url)) {
|
||||
const newChannels = json.map((item, index) => ({
|
||||
id: `custom-${customChannels.length + index + 1}`,
|
||||
name: item.name,
|
||||
url: item.url
|
||||
}));
|
||||
const updatedChannels = [...customChannels, ...newChannels];
|
||||
setCustomChannels(updatedChannels);
|
||||
localStorage.setItem('userChannels', JSON.stringify(updatedChannels));
|
||||
setJsonInput('');
|
||||
setError(null);
|
||||
} else {
|
||||
setError('JSON 格式不正确。请确保文件包含 name 和 url 字段的对象数组。');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('无法解析 JSON。请检查格式。');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
|
||||
const toggleUploadMode = () => {
|
||||
setIsUploadMode(!isUploadMode);
|
||||
setSelectedChannel(null);
|
||||
setEditingChannel(null);
|
||||
};
|
||||
|
||||
const handleEditChannel = (channel) => {
|
||||
setEditingChannel(channel);
|
||||
};
|
||||
|
||||
const handleUpdateChannel = () => {
|
||||
const updatedChannels = customChannels.map(ch =>
|
||||
ch.id === editingChannel.id ? editingChannel : ch
|
||||
);
|
||||
setCustomChannels(updatedChannels);
|
||||
localStorage.setItem('userChannels', JSON.stringify(updatedChannels));
|
||||
setEditingChannel(null);
|
||||
};
|
||||
|
||||
const handleDeleteChannel = (channelId) => {
|
||||
const updatedChannels = customChannels.filter(ch => ch.id !== channelId);
|
||||
setCustomChannels(updatedChannels);
|
||||
localStorage.setItem('userChannels', JSON.stringify(updatedChannels));
|
||||
};
|
||||
|
||||
const handleUrlSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (urlInput) {
|
||||
setSelectedChannel({ name: 'Custom URL', url: urlInput });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* 侧边栏 */}
|
||||
<div className={`relative flex flex-col bg-white shadow-lg transition-all duration-300 ease-in-out ${isSidebarOpen ? 'w-64' : 'w-16'}`}>
|
||||
<div className="flex-grow overflow-y-auto p-4">
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isSidebarOpen ? '' : 'hidden'}`}>频道列表</h2>
|
||||
<ul>
|
||||
{channels.map((channel) => (
|
||||
<li
|
||||
key={channel.id}
|
||||
className={`mb-2 p-2 cursor-pointer rounded ${
|
||||
selectedChannel && selectedChannel.id === channel.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'hover:bg-gray-100'
|
||||
} ${isSidebarOpen ? '' : 'text-center'}`}
|
||||
onClick={() => handleChannelSelect(channel)}
|
||||
>
|
||||
{isSidebarOpen ? channel.name : channel.name.charAt(0)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{customChannels.length > 0 && (
|
||||
<>
|
||||
<h3 className={`text-lg font-semibold mt-6 mb-2 ${isSidebarOpen ? '' : 'hidden'}`}>自定义频道</h3>
|
||||
<ul>
|
||||
{customChannels.map((channel) => (
|
||||
<li
|
||||
key={channel.id}
|
||||
className={`mb-2 p-2 cursor-pointer rounded ${
|
||||
selectedChannel && selectedChannel.id === channel.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'hover:bg-gray-100'
|
||||
} ${isSidebarOpen ? '' : 'text-center'}`}
|
||||
onClick={() => handleChannelSelect(channel)}
|
||||
>
|
||||
{isSidebarOpen ? channel.name : channel.name.charAt(0)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入字段和按钮 */}
|
||||
<div className={`p-4 border-t ${isSidebarOpen ? '' : 'hidden'}`}>
|
||||
<form onSubmit={handleUrlSubmit} className="flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder="输入或粘贴 M3U8 URL"
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full p-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<PlayIcon className="h-5 w-5 mr-2" />
|
||||
播放
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className={`mt-auto p-4 ${isSidebarOpen ? '' : 'hidden'}`}>
|
||||
<button
|
||||
onClick={toggleUploadMode}
|
||||
className="w-full p-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{isUploadMode ? '返回播放器' : '管理自定义频道'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="absolute top-0 right-0 mt-4 mr-4 p-1 rounded-full bg-gray-200 hover:bg-gray-300 focus:outline-none"
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<ChevronLeftIcon className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
{error && <p className="text-red-500 mb-4">{error}</p>}
|
||||
{isUploadMode ? (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-semibold mb-4">管理自定义频道</h2>
|
||||
<p className="mb-4">
|
||||
请在下方文本框中粘贴包含频道信息的 JSON 内容。JSON 应包含一个对象数组,每个对象有两个键:
|
||||
<code className="bg-gray-200 px-1 rounded">name</code> (频道名称) 和
|
||||
<code className="bg-gray-200 px-1 rounded">url</code> (流媒体 URL)。
|
||||
</p>
|
||||
<pre className="bg-gray-100 p-4 rounded mb-4">
|
||||
{JSON.stringify([
|
||||
{ name: "示例频道 1", url: "http://example.com/stream1.m3u8" },
|
||||
{ name: "示例频道 2", url: "http://example.com/stream2.m3u8" }
|
||||
], null, 2)}
|
||||
</pre>
|
||||
<textarea
|
||||
className="w-full h-64 p-2 border rounded mb-4"
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder="在此粘贴 JSON 内容..."
|
||||
/>
|
||||
<button
|
||||
onClick={handleJsonSubmit}
|
||||
className="w-full p-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors mb-8"
|
||||
>
|
||||
添加频道
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-4">已上传的自定义频道</h3>
|
||||
<ul className="space-y-4">
|
||||
{customChannels.map((channel) => (
|
||||
<li key={channel.id} className="bg-white p-4 rounded shadow">
|
||||
{editingChannel && editingChannel.id === channel.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editingChannel.name}
|
||||
onChange={(e) => setEditingChannel({...editingChannel, name: e.target.value})}
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editingChannel.url}
|
||||
onChange={(e) => setEditingChannel({...editingChannel, url: e.target.value})}
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateChannel}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="font-semibold">{channel.name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">{channel.url}</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditChannel(channel)}
|
||||
className="p-2 bg-yellow-500 text-white rounded hover:bg-yellow-600 transition-colors"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteChannel(channel.id)}
|
||||
className="p-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : selectedChannel ? (
|
||||
<div className="w-full max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-semibold mb-4">{selectedChannel.name}</h2>
|
||||
<VideoPlayer streamUrl={selectedChannel.url} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600">请从左侧选择一个频道或输入 M3U8 URL</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainApp;
|
||||
@@ -31,10 +31,21 @@ const VideoPlayer = ({ streamUrl }) => {
|
||||
|
||||
loadVideo();
|
||||
|
||||
// 添加 ESC 键监听器
|
||||
const handleEscKey = (event) => {
|
||||
if (event.key === 'Escape' && document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
|
||||
return () => {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
}
|
||||
// 移除 ESC 键监听器
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
};
|
||||
}, [streamUrl]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user