forked from OinkTechLtd/cdnplayerjs
287 lines
9.1 KiB
HTML
287 lines
9.1 KiB
HTML
<!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>
|