Files
cdnplayerjs/index.html
2026-05-19 09:31:42 +00:00

287 lines
9.1 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>