c4c3e6f445
Build and Push Docker Image / build (push) Successful in 17s
- 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
1367 lines
55 KiB
HTML
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>
|