forked from OinkTechLtd/cdnplayerjs
add player
This commit is contained in:
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# CDNPlayerJS
|
||||
|
||||
Статический плеер на базе `Playerjs` с поддержкой:
|
||||
|
||||
- прямых потоков (`.m3u8`, `.mp4`, и т.д.);
|
||||
- удалённых плейлистов `.m3u` и `.txt`;
|
||||
- шифрованных ссылок (AES-GCM) с TTL 24 часа.
|
||||
|
||||
## Как запустить локально
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8080
|
||||
```
|
||||
|
||||
Открой: `http://localhost:8080`
|
||||
|
||||
## Как передать поток
|
||||
|
||||
Только через адресную строку:
|
||||
|
||||
- `?src=https://example.com/live.m3u8`
|
||||
- `?file=https://example.com/live.m3u8`
|
||||
- `?stream=https://example.com/live.m3u8`
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
https://your-domain.com/?src=https%3A%2F%2Fexample.com%2Flive.m3u8
|
||||
```
|
||||
|
||||
Если открыть страницу с `src/file/stream`, приложение автоматически:
|
||||
|
||||
1. запускает поток;
|
||||
2. шифрует ссылку;
|
||||
3. заменяет URL в адресной строке на `?enc=...`.
|
||||
|
||||
Шифрованная ссылка активна 24 часа, после чего становится невалидной для всех.
|
||||
|
||||
## Плейлисты `.m3u` и `.txt`
|
||||
|
||||
Для `.m3u` поддерживаются строки `#EXTINF` + URL.
|
||||
|
||||
Для `.txt` поддерживаются форматы:
|
||||
- `https://example.com/stream.m3u8`
|
||||
- `Название|https://example.com/stream.m3u8`
|
||||
- `Название,https://example.com/stream.m3u8`
|
||||
|
||||
После загрузки список собирается во внутренний playlist PlayerJS, каналы можно переключать в плеере.
|
||||
|
||||
## Шифрованные ссылки
|
||||
|
||||
- Формат: `?enc=...` (опционально `&k=...` для кастомного ключа).
|
||||
- Алгоритм: AES-GCM.
|
||||
- Срок жизни токена: 24 часа.
|
||||
|
||||
> Важно: ключ в URL (`k`) — это обфускация, а не абсолютная криптографическая защита от целевого взлома.
|
||||
|
||||
## Деплой
|
||||
|
||||
### Netlify
|
||||
Проект уже содержит `netlify.toml`.
|
||||
|
||||
- Build command: не нужен
|
||||
- Publish directory: `.`
|
||||
|
||||
### Vercel
|
||||
Проект уже содержит `vercel.json`.
|
||||
|
||||
- Framework preset: `Other`
|
||||
- Build command: не нужен
|
||||
- Output directory: `.`
|
||||
286
index.html
Normal file
286
index.html
Normal file
@@ -0,0 +1,286 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CDNPlayerJS</title>
|
||||
<script src="playerjs.js" type="text/javascript"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
color: #f5f8ff;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
#playerjs {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
#status {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 9;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(9, 13, 24, 0.86);
|
||||
color: #dbe5ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="playerjs"></div>
|
||||
<div id="status">Ожидание ссылки в адресной строке (?src=... или ?enc=...).</div>
|
||||
|
||||
<script>
|
||||
let playerInstance = null;
|
||||
const DEFAULT_SECRET = 'cdnplayerjs';
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function setStatus(message) {
|
||||
document.getElementById('status').textContent = message;
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes) {
|
||||
let str = '';
|
||||
bytes.forEach((b) => { str += String.fromCharCode(b); });
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(input) {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - input.length % 4) % 4);
|
||||
const raw = atob(padded);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function getAesKey(secret) {
|
||||
const data = new TextEncoder().encode(secret.trim());
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return crypto.subtle.importKey('raw', digest, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
async function encryptText(plainText, secret) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await getAesKey(secret);
|
||||
const encoded = new TextEncoder().encode(plainText);
|
||||
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
||||
const merged = new Uint8Array(iv.length + cipher.byteLength);
|
||||
merged.set(iv, 0);
|
||||
merged.set(new Uint8Array(cipher), iv.length);
|
||||
return base64UrlEncode(merged);
|
||||
}
|
||||
|
||||
async function decryptText(token, secret) {
|
||||
const bytes = base64UrlDecode(token);
|
||||
const iv = bytes.slice(0, 12);
|
||||
const data = bytes.slice(12);
|
||||
const key = await getAesKey(secret);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
|
||||
return new TextDecoder().decode(plain);
|
||||
}
|
||||
|
||||
function safeTitle(title, index) {
|
||||
const cleaned = (title || `Канал ${index + 1}`).replace(/[\[\],]/g, ' ').trim();
|
||||
return cleaned || `Канал ${index + 1}`;
|
||||
}
|
||||
|
||||
function parsePlaylistText(text) {
|
||||
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const entries = [];
|
||||
let pendingTitle = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#EXTINF')) {
|
||||
pendingTitle = line.includes(',') ? line.split(',').slice(1).join(',').trim() : '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(line)) {
|
||||
entries.push({ title: pendingTitle, url: line });
|
||||
pendingTitle = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('|')) {
|
||||
const [title, url] = line.split('|');
|
||||
if (/^https?:\/\//i.test((url || '').trim())) {
|
||||
entries.push({ title: title.trim(), url: url.trim() });
|
||||
}
|
||||
} else if (line.includes(',')) {
|
||||
const [title, url] = line.split(',');
|
||||
if (/^https?:\/\//i.test((url || '').trim())) {
|
||||
entries.push({ title: title.trim(), url: url.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveUrl(baseUrl, value) {
|
||||
try {
|
||||
return new URL(value, baseUrl).toString();
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPlaylistContent(raw) {
|
||||
return raw.includes('#EXTM3U') || raw.includes('#EXTINF') || /https?:\/\//i.test(raw);
|
||||
}
|
||||
|
||||
function parsePlaylistFromInput(rawInput, baseUrl) {
|
||||
const entries = parsePlaylistText(rawInput);
|
||||
return entries
|
||||
.map((entry, index) => `[${safeTitle(entry.title, index)}]${resolveUrl(baseUrl, entry.url)}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
async function resolveSource(inputUrl) {
|
||||
const trimmed = inputUrl.trim();
|
||||
|
||||
if (hasPlaylistContent(trimmed) && !/^https?:\/\//i.test(trimmed)) {
|
||||
const fromInput = parsePlaylistFromInput(trimmed, window.location.href);
|
||||
if (!fromInput) {
|
||||
throw new Error('Плейлист из поля ввода пустой или неверного формата.');
|
||||
}
|
||||
return fromInput;
|
||||
}
|
||||
|
||||
const isPlaylist = /\.(m3u8?|txt)(\?|#|$)/i.test(trimmed);
|
||||
if (!isPlaylist || /\.m3u8(\?|#|$)/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
return inputUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(trimmed);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Не удалось загрузить плейлист (${response.status})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const entries = parsePlaylistText(text);
|
||||
if (!entries.length) {
|
||||
throw new Error('Плейлист пустой или неверного формата.');
|
||||
}
|
||||
|
||||
return entries
|
||||
.map((entry, index) => `[${safeTitle(entry.title, index)}]${entry.url}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function createPlayer(fileSource) {
|
||||
const container = document.getElementById('playerjs');
|
||||
container.innerHTML = '';
|
||||
|
||||
playerInstance = new Playerjs({
|
||||
id: 'playerjs',
|
||||
file: fileSource,
|
||||
autoplay: 1,
|
||||
poster: ''
|
||||
});
|
||||
}
|
||||
|
||||
async function startPlayback(raw) {
|
||||
if (!raw) {
|
||||
setStatus('Добавьте ссылку в адресную строку: ?src=https://...');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Загрузка источника...');
|
||||
try {
|
||||
const fileSource = await resolveSource(raw.trim());
|
||||
createPlayer(fileSource);
|
||||
setStatus('Поток запущен.');
|
||||
} catch (error) {
|
||||
setStatus(`Ошибка: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createEncryptedUrl(source, secret) {
|
||||
const payload = JSON.stringify({
|
||||
stream: source,
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + TTL_MS
|
||||
});
|
||||
const token = await encryptText(payload, secret);
|
||||
const url = new URL(window.location.href);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
url.searchParams.set('enc', token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function initFromUrl() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const source = query.get('src') || query.get('file') || query.get('stream');
|
||||
const enc = query.get('enc') || new URLSearchParams(window.location.hash.replace(/^#/, '')).get('enc');
|
||||
const key = query.get('k') || DEFAULT_SECRET;
|
||||
|
||||
if (source) {
|
||||
try {
|
||||
const encryptedUrl = await createEncryptedUrl(source, key);
|
||||
window.history.replaceState({}, '', encryptedUrl);
|
||||
setStatus('Ссылка зашифрована. Доступ к потоку активен 24 часа.');
|
||||
} catch (error) {
|
||||
setStatus(`Не удалось зашифровать ссылку: ${error.message}`);
|
||||
}
|
||||
startPlayback(source);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enc) {
|
||||
setStatus('Ожидание ссылки. Пример: ?src=https://example.com/live.m3u8');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await decryptText(enc, key);
|
||||
const payload = JSON.parse(json);
|
||||
const src = (payload && payload.stream) ? String(payload.stream).trim() : '';
|
||||
const expiresAt = Number(payload.expiresAt || 0);
|
||||
|
||||
if (!src) {
|
||||
throw new Error('В токене нет stream.');
|
||||
}
|
||||
|
||||
if (!expiresAt || Number.isNaN(expiresAt)) {
|
||||
throw new Error('В токене нет срока действия.');
|
||||
}
|
||||
|
||||
if (Date.now() > expiresAt) {
|
||||
const expiredAtText = new Date(expiresAt).toLocaleString('ru-RU');
|
||||
throw new Error(`Срок действия ссылки истёк (${expiredAtText}).`);
|
||||
}
|
||||
|
||||
const hoursLeft = Math.max(0, Math.ceil((expiresAt - Date.now()) / 3600000));
|
||||
setStatus(`Ссылка активна. Осталось примерно ${hoursLeft} ч.`);
|
||||
startPlayback(src);
|
||||
} catch (error) {
|
||||
setStatus(`Не удалось запустить поток: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
initFromUrl();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4
jquery.min.js
vendored
Normal file
4
jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
playerjs.js
Normal file
6
playerjs.js
Normal file
File diff suppressed because one or more lines are too long
17
vercel.json
Normal file
17
vercel.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"cleanUrls": true,
|
||||
"trailingSlash": false,
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "public, max-age=300" },
|
||||
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
|
||||
{ "key": "X-Content-Type-Options", "value": "nosniff" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user