Files
StreamHall/public/player.html
T
Stardream c4c3e6f445
Build and Push Docker Image / build (push) Successful in 17s
fix: instant cookie probe, default Auto quality, consistent control order
- Trigger a stream probe immediately when the upstream cookie field changes,
  instead of waiting for the next monitor cycle
- Remove the probeActive guard in checkLinkRow so a cookie-triggered probe is no
  longer dropped when it lands while the URL probe is still in flight (the
  probeToken check already handles superseded results)
- Default the HLS resolution menu to its "Auto" (ABR) entry rather than landing
  on a fixed (lowest) rung; start hls.js in auto level mode
- Keep source (视角) selector on the left and resolution on the right in both
  live and archive players, which previously rendered in opposite order
2026-06-02 00:59:55 +10:00

1367 lines
55 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/shaka-player.compiled.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 播放组件未加载',
drm_unavailable: '当前浏览器不支持此 DRM 播放',
drm_license_missing:'未配置 DRM License URL',
drm_certificate_missing:'未配置 FairPlay Certificate URL',
drm_playback_error:'DRM 授权播放失败',
drm_android_webview_unsupported:'当前内置浏览器不支持此 DRM 播放,请使用系统浏览器打开',
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',
drm_unavailable: 'This browser does not support the configured DRM playback',
drm_license_missing:'DRM License URL is not configured',
drm_certificate_missing:'FairPlay Certificate URL is not configured',
drm_playback_error:'DRM license playback failed',
drm_android_webview_unsupported:'This in-app browser does not support this DRM playback. Open it in your system browser.',
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] || PLAYER_I18N.en[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 parseHeaderConfig(input) {
const text = String(input || '').trim();
if (!text) return {};
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return Object.entries(parsed).reduce((acc, [key, value]) => {
if (key && value !== undefined && value !== null) acc[key] = String(value);
return acc;
}, {});
}
} catch (e) {}
return text.split(/\n+/).reduce((acc, line) => {
const idx = line.indexOf(':');
if (idx > 0) {
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key && value) acc[key] = value;
}
return acc;
}, {});
}
function getFairPlayContentId(initData) {
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
return shaka.util.FairPlayUtils.defaultGetContentId(text);
}
function transformFairPlayInitData(initData, initDataType, drmInfo) {
if (initDataType !== 'skd') return initData;
const contentId = getFairPlayContentId(initData);
return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, drmInfo.serverCertificate);
}
function fairPlayStringToUtf16Buffer(text) {
const buffer = new ArrayBuffer(text.length * 2);
const view = new Uint16Array(buffer);
for (let i = 0; i < text.length; i++) view[i] = text.charCodeAt(i);
return new Uint8Array(buffer);
}
function concatFairPlayInitData(initData, contentId, certificate) {
const init = new Uint8Array(initData);
const id = fairPlayStringToUtf16Buffer(contentId);
const cert = new Uint8Array(certificate);
const result = new Uint8Array(init.byteLength + 4 + id.byteLength + 4 + cert.byteLength);
const view = new DataView(result.buffer);
let offset = 0;
result.set(init, offset);
offset += init.byteLength;
view.setUint32(offset, id.byteLength, true);
offset += 4;
result.set(id, offset);
offset += id.byteLength;
view.setUint32(offset, cert.byteLength, true);
offset += 4;
result.set(cert, offset);
return result;
}
function getLegacyFairPlayContentId(initData) {
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
const raw = shaka.util.FairPlayUtils.defaultGetContentId(text) || text.replace(/^skd:\/\//i, '');
const query = raw.includes('?') ? raw.split('?').pop() : raw;
const params = new URLSearchParams(query.replace(/^.*?assetId=/, 'assetId='));
return params.get('assetId') || raw;
}
function transformLegacyFairPlayInitData(initData, certificate) {
const contentId = getLegacyFairPlayContentId(initData);
return concatFairPlayInitData(initData, contentId, certificate);
}
function normalizeFairPlayLicenseResponse(buffer, contentType = '') {
const data = new Uint8Array(buffer);
const type = String(contentType || '').toLowerCase();
const looksTextWrapped = type.includes('json') || type.includes('xml') || type.includes('text');
if (!looksTextWrapped || !window.shaka?.util?.FairPlayUtils || !window.shaka?.net?.NetworkingEngine) {
return data;
}
try {
const response = { data, headers: { 'content-type': contentType } };
shaka.util.FairPlayUtils.commonFairPlayResponse(shaka.net.NetworkingEngine.RequestType.LICENSE, response);
return response.data;
} catch (error) {
console.warn('Native FairPlay response unwrap skipped:', error);
return data;
}
}
function setupNativeFairPlayHls(video, url, drm, art, options = {}) {
if (!window.WebKitMediaKeys || !WebKitMediaKeys.isTypeSupported('com.apple.fps.1_0', 'video/mp4')) {
return null;
}
const licenseUrl = String(drm?.licenseUrl || '').trim();
const certificateUrl = String(drm?.certificateUrl || '').trim();
const headers = parseHeaderConfig(drm?.licenseHeaders || '');
const keySystem = 'com.apple.fps.1_0';
const sessions = [];
const disposers = [];
let disposed = false;
let loaded = false;
const showNativeError = (stage, error) => {
const detail = error?.message || error?.name || error?.type || error?.code || String(error || 'unknown');
console.error(`Native FairPlay Error [${stage}]:`, error);
art.notice.show = `${t('drm_playback_error')} (${stage}: ${detail})`;
};
const certificatePromise = fetch(certificateUrl, { cache: 'force-cache' }).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.arrayBuffer();
}).then(buffer => {
console.info('Native FairPlay certificate loaded:', certificateUrl, buffer.byteLength);
return new Uint8Array(buffer);
}).catch(error => {
showNativeError('certificate', error);
throw error;
});
const clearWaiting = () => {
loaded = true;
};
const handleNeedKey = event => {
console.info('Native FairPlay needkey:', event);
certificatePromise.then(certificate => {
if (disposed) return;
try {
const initData = event.initData || event.webkitInitData;
if (!initData || !initData.byteLength) throw new Error('missing initData');
const transformed = transformLegacyFairPlayInitData(initData, certificate);
console.info('Native FairPlay initData:', shaka.util.StringUtils.fromBytesAutoDetect(initData), 'contentId=', getLegacyFairPlayContentId(initData), initData.byteLength, transformed.byteLength);
let session = null;
try {
session = video.webkitKeys.createSession('video/mp4', transformed);
} catch (error) {
showNativeError('create-session', error);
return;
}
if (!session) {
showNativeError('create-session', new Error('empty session'));
return;
}
sessions.push(session);
const handleMessage = messageEvent => {
const message = messageEvent.message || messageEvent.webkitMessage;
if (!message || !message.byteLength) {
showNativeError('license-message', new Error('missing SPC message'));
return;
}
const licenseEndpoint = options.licenseProxyUrl || licenseUrl;
console.info('Native FairPlay license request:', licenseEndpoint, message.byteLength);
const requestHeaders = options.licenseProxyUrl
? { 'Content-Type': 'application/octet-stream', 'X-StreamHall-Viewer-Token': options.viewerToken || '' }
: { 'Content-Type': 'application/octet-stream', ...headers };
fetch(licenseEndpoint, {
method: 'POST',
headers: requestHeaders,
body: message
}).then(async response => {
const contentType = response.headers.get('content-type') || '';
console.info('Native FairPlay license status:', response.status, contentType);
const buffer = await response.arrayBuffer();
if (!response.ok) {
const text = new TextDecoder().decode(buffer.slice(0, 2048));
console.warn('Native FairPlay license error body:', text);
throw `HTTP ${response.status}: ${text}`;
}
return { buffer, contentType };
}).then(({ buffer, contentType }) => {
if (disposed) return;
console.info('Native FairPlay license response:', buffer.byteLength);
session.update(normalizeFairPlayLicenseResponse(buffer, contentType));
}).catch(error => showNativeError('license', error));
};
const handleKeyError = errorEvent => {
const code = session.error ? `${session.error.code || ''} ${session.error.systemCode || ''}`.trim() : '';
showNativeError('key-session', code ? new Error(code) : errorEvent);
};
session.addEventListener('webkitkeymessage', handleMessage);
session.addEventListener('webkitkeyerror', handleKeyError);
disposers.push(() => {
session.removeEventListener('webkitkeymessage', handleMessage);
session.removeEventListener('webkitkeyerror', handleKeyError);
});
} catch (error) {
showNativeError('needkey', error);
}
}).catch(error => showNativeError('certificate', error));
};
const handleVideoError = () => showNativeError('media', video.error || new Error('Native FairPlay media error'));
try {
video.webkitSetMediaKeys(new WebKitMediaKeys(keySystem));
} catch (error) {
showNativeError('mediakeys', error);
return null;
}
video.addEventListener('webkitneedkey', handleNeedKey);
video.addEventListener('loadeddata', clearWaiting);
video.addEventListener('playing', clearWaiting);
video.addEventListener('error', handleVideoError);
disposers.push(() => {
video.removeEventListener('webkitneedkey', handleNeedKey);
video.removeEventListener('loadeddata', clearWaiting);
video.removeEventListener('playing', clearWaiting);
video.removeEventListener('error', handleVideoError);
});
const timeout = window.setTimeout(() => {
if (!disposed && !loaded && video.readyState < 2) {
art.notice.show = `${t('drm_playback_error')} (native FairPlay timeout)`;
}
}, 15000);
video.src = url;
video.load();
return () => {
disposed = true;
window.clearTimeout(timeout);
disposers.splice(0).forEach(dispose => dispose());
try { video.removeAttribute('src'); video.load(); } catch (e) {}
};
}
function getDrmConfigs(link) {
const configs = Array.isArray(link?.drmConfigs) ? link.drmConfigs : Array.isArray(link?.drm_configs) ? link.drm_configs : [];
const normalized = configs.map(config => ({
drmType: String(config?.drmType || config?.drm_type || '').toLowerCase(),
licenseUrl: String(config?.licenseUrl || config?.license_url || '').trim(),
certificateUrl: String(config?.certificateUrl || config?.certificate_url || '').trim(),
licenseHeaders: String(config?.licenseHeaders || config?.license_headers || '').trim(),
pssh: String(config?.pssh || '').trim(),
playbackUrl: String(config?.playbackUrl || config?.playback_url || '').trim(),
playbackType: String(config?.playbackType || config?.playback_type || '').trim().toLowerCase(),
playback_url: String(config?.playback_url || '').trim()
})).filter(config => (config.drmType === 'widevine' || config.drmType === 'fairplay') && config.licenseUrl);
if (normalized.length) return normalized;
const legacyType = String(link?.drmType || link?.drm_type || '').toLowerCase();
const legacyLicense = String(link?.licenseUrl || link?.license_url || '').trim();
if ((legacyType === 'widevine' || legacyType === 'fairplay') && legacyLicense) {
return [{
drmType: legacyType,
licenseUrl: legacyLicense,
certificateUrl: String(link?.certificateUrl || link?.certificate_url || '').trim(),
licenseHeaders: String(link?.licenseHeaders || link?.license_headers || '').trim(),
pssh: String(link?.pssh || '').trim(),
playbackUrl: String(link?.playbackUrl || link?.playback_url || '').trim(),
playbackType: String(link?.playbackType || link?.playback_type || '').trim().toLowerCase(),
playback_url: String(link?.playback_url || '').trim()
}];
}
return [];
}
function isAppleWebKitFairPlayCapable() {
const ua = navigator.userAgent || '';
const isiOSWebKit = /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isDesktopSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
return !!window.WebKitMediaKeys || isiOSWebKit || isDesktopSafari;
}
function browserPrefersFairPlay() {
return isAppleWebKitFairPlayCapable();
}
function isAndroidRestrictedWebView() {
const ua = navigator.userAgent || '';
if (!/Android/i.test(ua)) return false;
return /Telegram/i.test(ua);
}
async function canUseWidevineKeySystem() {
if (!navigator.requestMediaKeySystemAccess) return false;
try {
await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
initDataTypes: ['cenc'],
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }],
distinctiveIdentifier: 'optional',
persistentState: 'optional',
sessionTypes: ['temporary']
}]);
return true;
} catch (error) {
return false;
}
}
function selectDrmConfig(link) {
const configs = getDrmConfigs(link);
if (!configs.length) return null;
if (browserPrefersFairPlay()) {
return configs.find(config => config.drmType === 'fairplay' && config.certificateUrl) || configs.find(config => config.drmType === 'fairplay') || null;
}
return configs.find(config => config.drmType === 'widevine') || null;
}
function linkUsesWidevine(link) {
return selectDrmConfig(link)?.drmType === 'widevine';
}
function linkUsesFairPlay(link) {
return selectDrmConfig(link)?.drmType === 'fairplay';
}
function linkUsesShakaDrm(link) {
return !!selectDrmConfig(link);
}
function safeDestroyHls(art) {
try {
const hls = art?.hls;
if (hls) hls.destroy();
} catch (error) {
if (!String(error?.message || error).includes('Cannot find instance of HLS')) {
console.warn('HLS cleanup skipped:', error);
}
}
}
function linkHasDrmConfigs(link) {
return getDrmConfigs(link).length > 0;
}
function drmConfigMatchScore(config, selected) {
if (!selected || config.drmType !== selected.drmType) return -1;
let score = 1;
const fields = ['licenseUrl', 'certificateUrl', 'playbackUrl', 'playbackType', 'pssh', 'licenseHeaders'];
for (const field of fields) {
const left = String(config[field] || '').trim();
const right = String(selected[field] || '').trim();
if (left && right && left !== right) return -1;
if (left === right) score += 1;
}
return score;
}
function getDrmConfigIndex(link, selected) {
const configs = getDrmConfigs(link);
let bestIndex = -1;
let bestScore = -1;
configs.forEach((config, index) => {
const score = drmConfigMatchScore(config, selected);
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
});
return bestIndex;
}
function formatShakaError(error) {
if (!error) return t('drm_playback_error');
if (error.code === 6001 && isAndroidRestrictedWebView()) {
return t('drm_android_webview_unsupported');
}
const parts = [];
if (error.code !== undefined) parts.push(`code ${error.code}`);
if (error.category !== undefined) parts.push(`category ${error.category}`);
if (error.severity !== undefined) parts.push(`severity ${error.severity}`);
return parts.length ? `${t('drm_playback_error')} (${parts.join(', ')})` : t('drm_playback_error');
}
function installLiveDurationGuard(art) {
const root = art?.template?.$player;
if (!root) return () => {};
const fix = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const text = node.nodeValue || '';
if (!text.includes(' / ')) return;
const fixed = text.replace(/\s+\/\s+\d{4,}:\d{2}:\d{2}/g, '');
if (fixed !== text) node.nodeValue = fixed;
});
};
const timer = window.setInterval(fix, 500);
fix();
return () => window.clearInterval(timer);
}
function installShakaQualityControl(art, player) {
const update = () => {
const tracks = player.getVariantTracks()
.filter(track => track.videoId !== null && track.videoId !== undefined && (track.height || track.bandwidth))
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
const byVideoTrack = new Map();
tracks.forEach(track => {
const key = track.videoId != null ? `video-${track.videoId}` : `${track.width || 0}x${track.height || 0}-${track.codecs || ''}-${Math.round((track.bandwidth || 0) / 250000)}`;
const current = byVideoTrack.get(key);
if (!current || track.active || (!current.active && (track.bandwidth || 0) > (current.bandwidth || 0))) {
byVideoTrack.set(key, track);
}
});
const unique = Array.from(byVideoTrack.values())
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
if (!unique.length) return;
const active = unique.find(track => track.active) || tracks.find(track => track.active);
const autoLabel = 'Auto';
const heightCounts = unique.reduce((acc, track) => {
const key = track.height || 0;
acc.set(key, (acc.get(key) || 0) + 1);
return acc;
}, new Map());
const qualityLabel = track => {
if (!track) return autoLabel;
const kbps = Math.round((track.videoBandwidth || track.bandwidth || 0) / 1000);
if (!track.height) return `${kbps}K`;
if ((heightCounts.get(track.height) || 0) <= 1) return `${track.height}P`;
return kbps >= 1000 ? `${track.height}P (${(kbps / 1000).toFixed(1)}M)` : `${track.height}P (${kbps}K)`;
};
const selectedLabel = active ? qualityLabel(active) : autoLabel;
const selector = unique.map(track => ({
html: qualityLabel(track),
value: track.id,
default: !!track.active
}));
selector.push({
html: autoLabel,
value: 'auto',
default: !active
});
const onSelect = item => {
if (item.value === 'auto') {
player.configure({ abr: { enabled: true } });
} else {
const selected = player.getVariantTracks().find(track => String(track.id) === String(item.value));
if (selected) {
player.configure({ abr: { enabled: false } });
player.selectVariantTrack(selected, true);
}
}
window.setTimeout(update, 150);
return item.html;
};
art.setting.update({
name: 'shaka-quality',
html: t('quality'),
tooltip: selectedLabel,
width: 200,
selector,
onSelect
});
art.controls.update({
name: 'shaka-quality',
position: 'right',
index: 11,
html: selectedLabel,
style: { padding: '0 10px', marginRight: '10px' },
selector,
onSelect
});
};
player.addEventListener('variantchanged', update);
window.setTimeout(update, 0);
window.setTimeout(update, 800);
return update;
}
function getLinkType(link) {
if (linkUsesShakaDrm(link)) return 'm3u8';
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) {
if (selectedDrm.playbackType) return selectedDrm.playbackType;
const drmPath = selectedDrm.playbackUrl.split('?')[0].toLowerCase();
if (drmPath.endsWith('.mpd')) return 'dash';
if (drmPath.endsWith('.m3u8')) return 'm3u8';
if (drmPath.endsWith('.flv')) return 'flv';
}
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) {
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) return selectedDrm.playback_url || selectedDrm.playbackUrl;
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 viewerToken = '';
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,
viewerToken,
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 {
viewerToken = data.viewerToken || '';
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');
viewerToken = data.viewerToken || '';
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.map((link, index) => ({ ...link, _sourceIndex: index }));
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)
}));
const initialLink = activeLinks[0] || null;
const initialUsesDrm = linkUsesShakaDrm(initialLink);
const hlsControlPlugins = initialUsesDrm ? [] : [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
];
playerInstance = new Artplayer({
container: '.artplayer-app',
url: quality[0].url,
type: quality[0].type,
quality: quality,
title: data.eventName,
autoplay: true,
isLive: data.streamLabel === 'LIVE' && !initialUsesDrm,
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) {
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
if (linkHasDrmConfigs(currentLink) && !selectDrmConfig(currentLink)) {
art.notice.show = t('drm_unavailable');
return;
}
if (linkUsesShakaDrm(currentLink)) {
const selectedDrm = selectDrmConfig(currentLink);
const fairplay = selectedDrm?.drmType === 'fairplay';
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
if (!licenseUrl) {
art.notice.show = t('drm_license_missing');
return;
}
if (fairplay && !certificateUrl) {
art.notice.show = t('drm_certificate_missing');
return;
}
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
canUseWidevineKeySystem().then(supported => {
if (!supported) art.notice.show = t('drm_android_webview_unsupported');
});
}
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
safeDestroyHls(art);
const linkIndex = Number.isInteger(currentLink?._sourceIndex) ? currentLink._sourceIndex : activeLinks.indexOf(currentLink);
const drmIndex = getDrmConfigIndex(currentLink, selectedDrm);
if (fairplay && isAppleWebKitFairPlayCapable() && window.WebKitMediaKeys) {
if (art.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
art.streamhallDurationGuard = installLiveDurationGuard(art);
const licenseProxyUrl = linkIndex >= 0 && drmIndex >= 0
? `/api?action=fairplay_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}`
: '';
const cleanup = setupNativeFairPlayHls(video, url, selectedDrm, art, { licenseProxyUrl, viewerToken });
if (!cleanup) {
art.notice.show = t('drm_unavailable');
return;
}
art.nativeFairPlayCleanup = cleanup;
art.on('destroy', () => {
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
});
return;
}
if (window.shaka?.polyfill) shaka.polyfill.installAll();
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
art.notice.show = t('drm_unavailable');
return;
}
if (art.shaka) art.shaka.destroy().catch(() => {});
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
art.streamhallDurationGuard = installLiveDurationGuard(art);
const player = new shaka.Player();
const headers = parseHeaderConfig(selectedDrm?.licenseHeaders || '');
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
const widevineProxyUrl = !fairplay && linkIndex >= 0 && drmIndex >= 0
? `/api?action=widevine_license&id=${encodeURIComponent(streamId)}&link=${encodeURIComponent(linkIndex)}&drm=${encodeURIComponent(drmIndex)}&vt=${encodeURIComponent(viewerToken || '')}`
: '';
const shakaLicenseUrl = widevineProxyUrl || licenseUrl;
const servers = { [keySystem]: shakaLicenseUrl };
const advanced = {};
if (fairplay) {
servers[fairplayLegacyKeySystem] = shakaLicenseUrl;
advanced[keySystem] = {
serverCertificateUri: certificateUrl,
audioRobustness: '',
videoRobustness: ''
};
advanced[fairplayLegacyKeySystem] = {
serverCertificateUri: certificateUrl,
audioRobustness: '',
videoRobustness: ''
};
} else {
advanced[keySystem] = {
audioRobustness: '',
videoRobustness: '',
persistentStateRequired: false,
distinctiveIdentifierRequired: false
};
}
const drmConfig = {
preferredKeySystems: fairplay ? [keySystem, fairplayLegacyKeySystem] : [keySystem],
servers,
advanced
};
player.configure({
drm: {
...drmConfig,
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
},
...(fairplay ? { streaming: { useNativeHlsForFairPlay: isAppleWebKitFairPlayCapable() } } : {})
});
player.getNetworkingEngine().registerRequestFilter((type, request) => {
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
if (fairplay) {
request.headers['Content-Type'] = request.headers['Content-Type'] || 'application/octet-stream';
}
if (widevineProxyUrl) {
request.headers['X-StreamHall-Viewer-Token'] = viewerToken || '';
}
if (Object.keys(headers).length) {
Object.assign(request.headers, headers);
}
});
if (fairplay) {
player.getNetworkingEngine().registerResponseFilter((type, response) => {
shaka.util.FairPlayUtils.commonFairPlayResponse(type, response);
});
}
player.addEventListener('error', event => {
console.error('Shaka DRM Error:', event.detail);
art.notice.show = formatShakaError(event.detail);
});
player.attach(video).then(() => player.load(url)).then(() => {
installShakaQualityControl(art, player);
}).catch(error => {
console.error('Shaka Load Error:', error);
art.notice.show = formatShakaError(error);
});
art.shaka = player;
art.on('destroy', () => {
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
player.destroy().catch(() => {});
});
return;
}
if (Hls.isSupported()) {
if (art.nativeFairPlayCleanup) {
art.nativeFairPlayCleanup();
art.nativeFairPlayCleanup = null;
}
if (art.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) {
art.streamhallDurationGuard();
art.streamhallDurationGuard = null;
}
safeDestroyHls(art);
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,
startLevel: -1,
debug: false
});
hls.loadSource(url);
hls.attachMedia(video);
// Keep ABR / auto quality active from the start so playback isn't
// pinned to the lowest rung when the source (re)loads.
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.currentLevel = -1; });
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: hlsControlPlugins
});
// The hls-control plugin labels its quality menu from hls.currentLevel, which
// is whatever rung hls.js happens to be on when the player becomes ready
// (often the lowest). Re-assert auto mode and refresh the menu so the default
// selection shows "Auto" rather than the lowest resolution.
playerInstance.on('ready', () => {
const root = playerInstance.template?.$player;
// Default the resolution menu to its "Auto" (ABR) entry instead of a fixed
// rung. The plugin tags each entry with data-value; Auto is value -1. Clicking
// it runs the plugin's own onSelect, which puts hls.js in auto mode and marks
// Auto as the selected item in both the control bar and the settings panel.
const autoItem = root?.querySelector('.art-control-hls-quality .art-selector-item[data-value="-1"]');
if (autoItem) autoItem.click();
// The native source (视角) control and the plugin resolution control get
// inserted in opposite DOM order under live vs archive mode, so their
// left/right positions flip. Force the archive layout in both: source
// selector on the left, resolution on the right.
const sourceCtrl = root?.querySelector('.art-control-quality');
const resCtrl = root?.querySelector('.art-control-hls-quality');
if (sourceCtrl && resCtrl && sourceCtrl.parentNode === resCtrl.parentNode) {
resCtrl.parentNode.insertBefore(sourceCtrl, resCtrl);
}
});
startPlaybackMonitor(data, password);
}
});
</script>
</body>
</html>