Files
StreamHall/public/player.html
T
Stardream 326101958a
Build and Push Docker Image / build (push) Successful in 32s
Initial release
2026-05-20 15:25:51 +10:00

708 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>播放器</title>
<script src="/vendor/hls.min.js"></script>
<script src="/vendor/flv.min.js"></script>
<script src="/vendor/dash.all.min.js"></script>
<script src="/vendor/artplayer.js"></script>
<script src="/vendor/artplayer-plugin-hls-control.js"></script>
<style>
* {
box-sizing: border-box;
}
body {
height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
}
.artplayer-app {
width: 100%;
height: 100%;
}
.hidden {
display: none !important;
}
.loader {
width: 50px;
height: 50px;
margin: 0 auto;
border: 4px solid #6b7280;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-container {
width: min(100% - 36px, 460px);
padding: 24px;
text-align: center;
}
.loading-container.waiting-live {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px;
background: rgba(17, 24, 39, 0.72);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.36);
}
.loading-container.waiting-live .loader {
display: none;
}
#msg {
margin: 16px 0 0;
font-weight: 800;
}
.loading-container.waiting-live #msg {
margin-top: 0;
font-size: 1.08rem;
}
.sub-msg {
margin: 8px 0 0;
color: #aeb8c8;
font-size: 0.92rem;
line-height: 1.6;
}
.password-modal {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.9);
padding: 20px;
}
.password-card {
width: min(100%, 380px);
border-radius: 8px;
padding: 28px;
background: #1f2937;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.password-card h2 {
margin: 0 0 16px;
font-size: 1.25rem;
}
.password-card input,
.password-card button {
width: 100%;
border: 0;
border-radius: 6px;
padding: 12px;
font: inherit;
}
.password-card input {
margin-bottom: 14px;
color: #fff;
background: #374151;
outline: 1px solid #4b5563;
}
.password-card button {
color: #fff;
background: #4f46e5;
font-weight: 800;
cursor: pointer;
}
.error {
margin: 12px 0 0;
color: #ef4444;
font-size: 0.9rem;
text-align: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="loading-container" class="loading-container">
<div class="loader"></div>
<p id="msg"></p>
<p id="sub-msg" class="sub-msg"></p>
</div>
<div id="player-container" class="artplayer-app hidden"></div>
<div id="password-modal" class="password-modal hidden">
<div class="password-card">
<h2 id="pwd-title"></h2>
<form id="password-form">
<input type="password" id="pwd-in" required>
<button type="submit" id="pwd-submit"></button>
<p id="pwd-err" class="error hidden"></p>
</form>
</div>
</div>
<script>
// Minimal i18n: read language preference set by the main site (index.html / admin.html).
const PLAYER_I18N = {
zh: {
loading: '正在加载...',
pwd_title: '需要密码',
pwd_placeholder: '请输入密码',
pwd_submit: '确 定',
pwd_error: '密码错误',
invalid_id: '无效的直播 ID',
check_failed: '检测失败',
no_stream: '没有监测到推流信息',
no_sources: '暂无播放源',
checking: '正在检测推流信息...',
checking_sub: '检测到推流后会自动进入播放器。',
waiting_sub: '正在自动检测,推流开始后会自动进入播放器。',
resuming_sub: '正在自动检测,推流恢复后会自动进入播放器。',
flv_unsupported: '当前浏览器不支持 FLV 播放',
hls_unsupported: '当前浏览器不支持 HLS 播放',
dash_unavailable: 'DASH 播放组件未加载',
quality: '画质',
// API error codes from server
'err.stream_not_found': '直播不存在',
'err.stream_disabled': '直播已关闭',
'err.stream_not_found_or_disabled':'直播不存在或已关闭',
'err.auth_incorrect_password': '密码错误',
'err.missing_session_id': '缺少会话 ID',
'err.viewer_session_not_found': '观看会话不存在',
'err.auth_required': '未登录或会话已过期',
'err.server_error': '服务器内部错误',
},
en: {
loading: 'Loading...',
pwd_title: 'Password required',
pwd_placeholder: 'Enter password',
pwd_submit: 'OK',
pwd_error: 'Incorrect password',
invalid_id: 'Invalid stream ID',
check_failed: 'Check failed',
no_stream: 'No stream detected',
no_sources: 'No playback sources',
checking: 'Checking for stream...',
checking_sub: 'The player will start automatically once a stream is detected.',
waiting_sub: 'Auto-checking. The player will start when the stream begins.',
resuming_sub: 'Auto-checking. The player will resume when the stream recovers.',
flv_unsupported: 'FLV playback is not supported in this browser',
hls_unsupported: 'HLS playback is not supported in this browser',
dash_unavailable: 'DASH player component not loaded',
quality: 'Quality',
// API error codes from server
'err.stream_not_found': 'Stream not found',
'err.stream_disabled': 'Stream is disabled',
'err.stream_not_found_or_disabled':'Stream not found or disabled',
'err.auth_incorrect_password': 'Incorrect password',
'err.missing_session_id': 'Missing session ID',
'err.viewer_session_not_found': 'Viewer session not found',
'err.auth_required': 'Not authenticated or session expired',
'err.server_error': 'Internal server error',
},
};
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh';
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || key;
// Initialise static text in the HTML that cannot use data-i18n.
document.getElementById('msg').textContent = t('loading');
document.getElementById('pwd-title').textContent = t('pwd_title');
document.getElementById('pwd-in').placeholder = t('pwd_placeholder');
document.getElementById('pwd-submit').textContent = t('pwd_submit');
document.getElementById('pwd-err').textContent = t('pwd_error');
// Set initial page title from cached site settings (same cache key as index.html).
try {
const cached = JSON.parse(localStorage.getItem('site_settings_cache') || 'null');
if (cached && cached.site_title) document.title = cached.site_title;
} catch (e) { /* ignore */ }
function hexToArrayBuffer(hex) {
hex = hex.trim();
if (hex.length !== 32) throw new Error("AES key must be 16 bytes (32 hex chars)");
const arr = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
arr[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return arr.buffer;
}
function normalizeHexKey(hex) {
return String(hex || '').trim().replace(/^0x/i, '').replace(/\s+/g, '');
}
function decodeProxyTarget(url) {
try {
const parsed = new URL(url, window.location.href);
const parts = parsed.pathname.split('/');
if (parts.length < 5 || parts[1] !== 'proxy' || parts[2] !== 'hls') return url;
const encoded = parts[4].replace(/-/g, '+').replace(/_/g, '/');
const padded = encoded + '='.repeat((4 - encoded.length % 4) % 4);
return decodeURIComponent(escape(atob(padded)));
} catch (e) {
return url;
}
}
function parseHlsKeyOverride(input) {
const text = String(input || '').trim();
if (!text) return null;
const direct = normalizeHexKey(text);
if (/^[0-9a-fA-F]{32}$/.test(direct)) return { defaultKey: direct, rules: [] };
const rules = [];
text.split(/\n+/).forEach(line => {
const trimmed = line.trim();
if (!trimmed) return;
const parts = trimmed.split(/[:=]/);
if (parts.length < 2) return;
const pattern = parts.shift().trim();
const key = normalizeHexKey(parts.join(':'));
if (pattern && /^[0-9a-fA-F]{32}$/.test(key)) rules.push({ pattern, key });
});
return rules.length ? { defaultKey: '', rules } : null;
}
function selectHlsKeyOverride(config, requestUrl) {
if (!config) return '';
const targetUrl = decodeProxyTarget(requestUrl || '');
const rule = config.rules.find(item => targetUrl.includes(item.pattern) || String(requestUrl || '').includes(item.pattern));
return rule ? rule.key : config.defaultKey;
}
function hexToBase64Url(hex) {
const clean = hex.trim().replace(/^0x/i, '');
if (!/^[0-9a-fA-F]+$/.test(clean) || clean.length % 2 !== 0) return hex.trim();
let binary = '';
for (let i = 0; i < clean.length; i += 2) {
binary += String.fromCharCode(parseInt(clean.slice(i, i + 2), 16));
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function normalizeClearKeyValue(value) {
const text = String(value || '').trim();
return /^[0-9a-fA-F]{32}$/.test(text.replace(/^0x/i, '')) ? hexToBase64Url(text) : text;
}
function buildClearKeyProtectionData(input) {
if (!input) return null;
let parsed;
try {
parsed = JSON.parse(input);
} catch (e) {
parsed = input.split(/[\n,]+/).reduce((acc, item) => {
const parts = item.split(/[:=]/);
if (parts.length >= 2) acc[parts[0].trim()] = parts.slice(1).join(':').trim();
return acc;
}, {});
}
const source = parsed.clearkeys || (parsed.kid && parsed.key ? { [parsed.kid]: parsed.key } : parsed);
const clearkeys = Object.entries(source).reduce((acc, [kid, key]) => {
if (kid && key) acc[normalizeClearKeyValue(kid)] = normalizeClearKeyValue(key);
return acc;
}, {});
return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null;
}
function getLinkType(link) {
if (link.type) return link.type;
const path = (link.url || '').split('?')[0].toLowerCase();
if (path.endsWith('.mpd')) return 'dash';
if (path.endsWith('.m3u8')) return 'm3u8';
if (path.endsWith('.flv')) return 'flv';
return '';
}
function getPlaybackUrl(link) {
return link.playback_url || link.url;
}
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const streamId = urlParams.get('id');
const els = {
loading: document.getElementById('loading-container'),
player: document.getElementById('player-container'),
pwdModal: document.getElementById('password-modal'),
msg: document.getElementById('msg'),
subMsg: document.getElementById('sub-msg')
};
const livePollMs = 5000;
const playbackMonitorMs = 10000;
let livePollTimer = null;
let playbackMonitorTimer = null;
let viewerHeartbeatTimer = null;
let viewerSessionId = '';
let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || '';
let playerInstance = null;
let playbackMisses = 0;
if (!viewerVisitorId) {
viewerVisitorId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem('streamhall_visitor_id', viewerVisitorId);
}
const applyPlayerTitle = (data = {}) => {
if (!data.eventName) return;
document.title = data.siteTitle ? `${data.eventName} - ${data.siteTitle}` : data.eventName;
};
const applySiteIcon = (url = '') => {
const iconUrl = String(url || '').trim();
let link = document.querySelector('link[rel="icon"]');
if (!iconUrl) {
if (link) link.remove();
return;
}
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = iconUrl;
};
const setLoadingState = (message, detail = '', waiting = false) => {
els.player.classList.add('hidden');
els.loading.classList.remove('hidden');
els.loading.classList.toggle('waiting-live', waiting);
els.msg.textContent = message;
els.subMsg.textContent = detail;
};
const clearLivePoll = () => {
if (livePollTimer) window.clearInterval(livePollTimer);
livePollTimer = null;
};
const clearPlaybackMonitor = () => {
if (playbackMonitorTimer) window.clearInterval(playbackMonitorTimer);
playbackMonitorTimer = null;
playbackMisses = 0;
};
const destroyPlayer = () => {
clearPlaybackMonitor();
if (!playerInstance) return;
try {
playerInstance.destroy(false);
} catch (e) {
console.warn('Player destroy failed:', e);
}
playerInstance = null;
els.player.innerHTML = '';
};
const viewerState = () => document.hidden ? 'hidden' : 'viewing';
const postViewerEvent = async (action, payload = {}) => {
const res = await fetch(`/api?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.status !== 'success') throw new Error(t('err.' + (json.code || '')) || json.code || action);
return json.data || {};
};
const startViewerStats = async () => {
if (viewerSessionId) return;
try {
const data = await postViewerEvent('viewer_start', {
id: streamId,
visitorId: viewerVisitorId,
referer: document.referrer || '',
state: viewerState()
});
viewerSessionId = data.sessionId || '';
if (!viewerSessionId) return;
viewerHeartbeatTimer = window.setInterval(() => {
postViewerEvent('viewer_heartbeat', {
sessionId: viewerSessionId,
state: viewerState()
}).catch(() => {});
}, 15000);
} catch (e) {
console.warn('Viewer stats start failed:', e);
}
};
const endViewerStats = () => {
if (viewerHeartbeatTimer) window.clearInterval(viewerHeartbeatTimer);
viewerHeartbeatTimer = null;
if (!viewerSessionId) return;
const payload = JSON.stringify({ sessionId: viewerSessionId, state: 'ended' });
try {
navigator.sendBeacon('/api?action=viewer_end', new Blob([payload], { type: 'application/json' }));
} catch (e) {
fetch('/api?action=viewer_end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
}).catch(() => {});
}
viewerSessionId = '';
};
window.addEventListener('pagehide', endViewerStats);
if (!streamId) {
setLoadingState(t('invalid_id'), '', true);
document.title = t('invalid_id');
return;
}
const fetchData = async (pwd = null) => {
const url = `/api?action=${pwd ? 'verify_password' : 'get_player_data'}${pwd ? '' : '&id=' + encodeURIComponent(streamId)}`;
const opts = pwd ? {
method: 'POST',
body: JSON.stringify({ id: streamId, password: pwd }),
headers: { 'Content-Type': 'application/json' }
} : {};
const res = await fetch(url, opts);
const json = await res.json();
if (json.status !== 'success') throw new Error(t('err.' + (json.code || '')) || json.code || '');
return json.data;
};
const checkPlayerStream = async (password = '') => {
const res = await fetch('/api?action=check_player_stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: streamId, password })
});
const json = await res.json();
if (json.status !== 'success') throw new Error(t('err.' + (json.code || '')) || json.code || t('check_failed'));
return json.data || { valid: false };
};
const waitForLiveAndStart = (data, password = '') => {
clearLivePoll();
destroyPlayer();
if (!data.links || data.links.length === 0) {
setLoadingState(t('no_sources'), '', true);
return;
}
let started = false;
const probeAndStart = async (showChecking = false) => {
if (started) return;
if (showChecking) {
setLoadingState(t('checking'), t('checking_sub'));
}
try {
const result = await checkPlayerStream(password);
if (result.valid) {
started = true;
clearLivePoll();
initPlayer(data, result.url || '', password);
return;
}
setLoadingState(t('no_stream'), t('waiting_sub'), true);
} catch (e) {
setLoadingState(e.message || t('no_stream'), t('waiting_sub'), true);
}
};
probeAndStart(true);
livePollTimer = window.setInterval(() => probeAndStart(false), livePollMs);
};
try {
const data = await fetchData();
applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || '');
if (data.requires_password) {
els.loading.classList.add('hidden');
els.pwdModal.classList.remove('hidden');
document.getElementById('pwd-in').focus();
} else {
startViewerStats();
waitForLiveAndStart(data);
}
} catch (e) {
setLoadingState(e.message, '', true);
}
document.getElementById('password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const pwd = document.getElementById('pwd-in').value;
try {
const data = await fetchData(pwd);
applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || '');
els.pwdModal.classList.add('hidden');
startViewerStats();
waitForLiveAndStart(data, pwd);
} catch (err) {
const errEl = document.getElementById('pwd-err');
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
function startPlaybackMonitor(data, password = '') {
clearPlaybackMonitor();
playbackMonitorTimer = window.setInterval(async () => {
try {
const result = await checkPlayerStream(password);
playbackMisses = result.valid ? 0 : playbackMisses + 1;
} catch (e) {
playbackMisses += 1;
}
if (playbackMisses >= 2) {
setLoadingState(t('no_stream'), t('resuming_sub'), true);
waitForLiveAndStart(data, password);
}
}, playbackMonitorMs);
}
function initPlayer(data, preferredUrl = '', password = '') {
clearLivePoll();
destroyPlayer();
els.loading.classList.add('hidden');
els.loading.classList.remove('waiting-live');
els.player.classList.remove('hidden');
const links = data.links || [];
if (links.length === 0) {
els.player.classList.add('hidden');
els.loading.classList.remove('hidden');
els.msg.textContent = t('no_sources');
return;
}
const activeLinks = [...links];
const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl);
if (preferredIndex > 0) {
activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]);
}
const quality = activeLinks.map((l, i) => ({
default: i === 0,
html: l.name,
url: getPlaybackUrl(l),
type: getLinkType(l)
}));
playerInstance = new Artplayer({
container: '.artplayer-app',
url: quality[0].url,
type: quality[0].type,
quality: quality,
title: data.eventName,
autoplay: true,
volume: 0.5,
autoSize: true,
fullscreen: true,
fullscreenWeb: true,
setting: true,
pip: true,
customType: {
flv: function (video, url, art) {
if (flvjs.isSupported()) {
const flv = flvjs.createPlayer({ type: 'flv', url: url });
flv.attachMediaElement(video);
flv.load();
art.flv = flv;
art.on('destroy', () => flv.destroy());
} else {
art.notice.show = t('flv_unsupported');
}
},
m3u8: function (video, url, art) {
if (Hls.isSupported()) {
if (art.hls) art.hls.destroy();
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
class CustomLoader extends Hls.DefaultConfig.loader {
loadInternal() {
const { context } = this;
const overrideKey = keyOverride && context && context.keyInfo ? selectHlsKeyOverride(keyOverride, context.url) : '';
if (overrideKey) {
try {
const keyData = hexToArrayBuffer(overrideKey);
this.loader = {
readyState: 4,
status: 200,
statusText: 'OK',
responseType: 'arraybuffer',
response: keyData,
responseText: keyData,
responseURL: context.url
};
this.readystatechange();
return;
} catch (e) {
console.error('Key Override Error:', e);
}
}
super.loadInternal();
}
}
const hls = new Hls({
enableSoftwareAES: !!keyOverride,
loader: keyOverride ? CustomLoader : Hls.DefaultConfig.loader,
debug: false
});
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on('destroy', () => hls.destroy());
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
} else {
art.notice.show = t('hls_unsupported');
}
},
dash: function (video, url, art) {
if (!window.dashjs) {
art.notice.show = t('dash_unavailable');
return;
}
if (art.dash) art.dash.destroy();
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
const dash = dashjs.MediaPlayer().create();
const protectionData = currentLink ? buildClearKeyProtectionData(currentLink.clearkey) : null;
if (protectionData) dash.setProtectionData(protectionData);
dash.initialize(video, url, art.option.autoplay);
art.dash = dash;
art.on('destroy', () => dash.destroy());
}
},
plugins: [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
]
});
startPlaybackMonitor(data, password);
}
});
</script>
</body>
</html>