add a landing page of player

This commit is contained in:
2024-10-21 23:30:07 +11:00
parent f5e229d048
commit 4e0c780820
6 changed files with 442 additions and 244 deletions
+61
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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]);