mirror of
https://github.com/wahyd4/ai-projects.git
synced 2026-08-09 05:06:01 +10:00
Create m3u8 live stream player
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/build/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
backend/venv/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# 开发环境
|
||||
.env
|
||||
.vscode/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,60 @@
|
||||
# M3U8 直播流播放器
|
||||
|
||||
这是一个使用 React 和 Flask 构建的 M3U8 直播流播放器。
|
||||
|
||||
## 项目设置
|
||||
|
||||
### 前端设置
|
||||
|
||||
1. 进入前端目录:
|
||||
```
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### 后端设置
|
||||
|
||||
1. 进入后端目录:
|
||||
```
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. 创建虚拟环境:
|
||||
```
|
||||
python -m venv venv
|
||||
```
|
||||
|
||||
3. 激活虚拟环境:
|
||||
- 在 Unix 或 MacOS 上:
|
||||
```
|
||||
source venv/bin/activate
|
||||
```
|
||||
- 在 Windows 上:
|
||||
```
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
4. 安装依赖:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 运行应用
|
||||
|
||||
1. 启动后端:
|
||||
```
|
||||
cd backend
|
||||
flask run
|
||||
```
|
||||
|
||||
2. 在另一个终端中启动前端:
|
||||
```
|
||||
cd frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
3. 在浏览器中访问 http://localhost:3000 查看应用。
|
||||
@@ -0,0 +1,26 @@
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app, resources={r"/*": {"origins": "*"}}) # 允许所有来源,仅用于开发环境
|
||||
|
||||
channels = [
|
||||
{"id": 1, "name": "BBC News", "url": "https://cdn4.skygo.mn/live/disk1/BBC_News/HLSv3-FTA/BBC_News.m3u8"},
|
||||
{"id": 2, "name": "CCTV5", "url": "https://node1.olelive.com:6443/live/CCTV5HD/hls.m3u8"},
|
||||
{"id": 3, "name": "CNN", "url": "https://turnerlive.warnermediacdn.com/hls/live/586495/cnngo/cnn_slate/VIDEO_3_1464000.m3u8"},
|
||||
{"id": 4, "name": "Bloomberg", "url": "https://liveprodusphoenixeast.akamaized.net/USPhx-HD/Channel-TX-USPhx-AWS-virginia-1/Source-USPhx-16k-1-s6lk2-BP-07-02-81ykIWnsMsg_live.m3u8"},
|
||||
]
|
||||
|
||||
@app.route('/api/channels', methods=['GET'])
|
||||
def get_channels():
|
||||
return jsonify(channels)
|
||||
|
||||
@app.route('/api/stream/<int:channel_id>', methods=['GET'])
|
||||
def get_stream_url(channel_id):
|
||||
channel = next((c for c in channels if c['id'] == channel_id), None)
|
||||
if channel:
|
||||
return jsonify({'streamUrl': channel['url']})
|
||||
return jsonify({'error': 'Channel not found'}), 404
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask==2.3.3
|
||||
Flask-CORS==4.0.0
|
||||
Werkzeug==2.3.7
|
||||
Generated
+30428
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "m3u8-player-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^0.27.2",
|
||||
"hls.js": "^1.5.15",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"@heroicons/react": "^2.0.18"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npx react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="M3U8直播流播放器"
|
||||
/>
|
||||
<title>M3U8播放器</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>您需要启用JavaScript才能运行此应用。</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
function App() {
|
||||
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);
|
||||
|
||||
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 allChannels = [...response.data, ...storedChannels];
|
||||
const channel = allChannels.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));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* 侧边栏 */}
|
||||
<div className={`relative bg-white shadow-lg transition-all duration-300 ease-in-out ${isSidebarOpen ? 'w-64' : 'w-16'}`}>
|
||||
<div className="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 className={`mt-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>
|
||||
</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">请从左侧选择一个频道</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
const VideoPlayer = ({ streamUrl }) => {
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let hls;
|
||||
|
||||
const loadVideo = () => {
|
||||
if (videoRef.current) {
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(streamUrl);
|
||||
hls.attachMedia(videoRef.current);
|
||||
} else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoRef.current.src = streamUrl;
|
||||
} else {
|
||||
console.error('This browser does not support HLS');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVideo();
|
||||
|
||||
return () => {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
}
|
||||
};
|
||||
}, [streamUrl]);
|
||||
|
||||
return (
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/aspect-ratio'),
|
||||
],
|
||||
}
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "m3u8-player",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/Users/junv/code/ai-projects/venv/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from flask.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user