Create m3u8 live stream player

This commit is contained in:
2024-10-21 23:30:07 +11:00
parent 82c44dfe01
commit 7aace4955d
15 changed files with 30946 additions and 0 deletions
+21
View File
@@ -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
+60
View File
@@ -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 查看应用。
+26
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
Flask==2.3.3
Flask-CORS==4.0.0
Werkzeug==2.3.7
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+17
View File
@@ -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>
+255
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+11
View File
@@ -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>
);
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/aspect-ratio'),
],
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "m3u8-player",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}
Executable
+8
View File
@@ -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())