Files
StreamHall/public/player.html
T
Stardream 601eb0247f
Build and Push Docker Image / build (push) Successful in 14s
feat: DRM playback with Widevine and FairPlay via Shaka Player
2026-05-24 00:18:52 +10:00

1018 lines
38 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 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()
})).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()
}];
}
return [];
}
function browserPrefersFairPlay() {
const ua = navigator.userAgent || '';
const isSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
return !!window.WebKitMediaKeys || isSafari;
}
function isAndroidRestrictedWebView() {
const ua = navigator.userAgent || '';
if (!/Android/i.test(ua)) return false;
return /Telegram/i.test(ua) || /; wv\)/i.test(ua) || /\bwv\b/i.test(ua);
}
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 linkHasDrmConfigs(link) {
return getDrmConfigs(link).length > 0;
}
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)
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
const unique = [];
const seen = new Set();
tracks.forEach(track => {
const key = `${track.height || 0}-${Math.round((track.bandwidth || 0) / 1000)}`;
if (seen.has(key)) return;
seen.add(key);
unique.push(track);
});
if (!unique.length) return;
const active = unique.find(track => track.active) || tracks.find(track => track.active);
const autoLabel = 'Auto';
const selectedLabel = active?.height ? `${active.height}P` : autoLabel;
const selector = unique.map(track => ({
html: track.height ? `${track.height}P` : `${Math.round((track.bandwidth || 0) / 1000)}K`,
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 (link.type) return link.type;
const path = (link.url || '').split('?')[0].toLowerCase();
if (path.endsWith('.mpd')) return 'dash';
if (path.endsWith('.m3u8')) return 'm3u8';
if (path.endsWith('.flv')) return 'flv';
return '';
}
function getPlaybackUrl(link) {
return link.playback_url || link.url;
}
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const streamId = urlParams.get('id');
const els = {
loading: document.getElementById('loading-container'),
player: document.getElementById('player-container'),
pwdModal: document.getElementById('password-modal'),
msg: document.getElementById('msg'),
subMsg: document.getElementById('sub-msg')
};
const livePollMs = 5000;
const playbackMonitorMs = 10000;
let livePollTimer = null;
let playbackMonitorTimer = null;
let viewerHeartbeatTimer = null;
let viewerSessionId = '';
let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || '';
let playerInstance = null;
let playbackMisses = 0;
if (!viewerVisitorId) {
viewerVisitorId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem('streamhall_visitor_id', viewerVisitorId);
}
const applyPlayerTitle = (data = {}) => {
if (!data.eventName) return;
document.title = data.siteTitle ? `${data.eventName} - ${data.siteTitle}` : data.eventName;
};
const applySiteIcon = (url = '') => {
const iconUrl = String(url || '').trim();
let link = document.querySelector('link[rel="icon"]');
if (!iconUrl) {
if (link) link.remove();
return;
}
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = iconUrl;
};
const setLoadingState = (message, detail = '', waiting = false) => {
els.player.classList.add('hidden');
els.loading.classList.remove('hidden');
els.loading.classList.toggle('waiting-live', waiting);
els.msg.textContent = message;
els.subMsg.textContent = detail;
};
const clearLivePoll = () => {
if (livePollTimer) window.clearInterval(livePollTimer);
livePollTimer = null;
};
const clearPlaybackMonitor = () => {
if (playbackMonitorTimer) window.clearInterval(playbackMonitorTimer);
playbackMonitorTimer = null;
playbackMisses = 0;
};
const destroyPlayer = () => {
clearPlaybackMonitor();
if (!playerInstance) return;
try {
playerInstance.destroy(false);
} catch (e) {
console.warn('Player destroy failed:', e);
}
playerInstance = null;
els.player.innerHTML = '';
};
const viewerState = () => document.hidden ? 'hidden' : 'viewing';
const postViewerEvent = async (action, payload = {}) => {
const res = await fetch(`/api?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.status !== 'success') throw new Error(t('err.' + (json.code || '')) || json.code || action);
return json.data || {};
};
const startViewerStats = async () => {
if (viewerSessionId) return;
try {
const data = await postViewerEvent('viewer_start', {
id: streamId,
visitorId: viewerVisitorId,
referer: document.referrer || '',
state: viewerState()
});
viewerSessionId = data.sessionId || '';
if (!viewerSessionId) return;
viewerHeartbeatTimer = window.setInterval(() => {
postViewerEvent('viewer_heartbeat', {
sessionId: viewerSessionId,
state: viewerState()
}).catch(() => {});
}, 15000);
} catch (e) {
console.warn('Viewer stats start failed:', e);
}
};
const endViewerStats = () => {
if (viewerHeartbeatTimer) window.clearInterval(viewerHeartbeatTimer);
viewerHeartbeatTimer = null;
if (!viewerSessionId) return;
const payload = JSON.stringify({ sessionId: viewerSessionId, state: 'ended' });
try {
navigator.sendBeacon('/api?action=viewer_end', new Blob([payload], { type: 'application/json' }));
} catch (e) {
fetch('/api?action=viewer_end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
}).catch(() => {});
}
viewerSessionId = '';
};
window.addEventListener('pagehide', endViewerStats);
if (!streamId) {
setLoadingState(t('invalid_id'), '', true);
document.title = t('invalid_id');
return;
}
const fetchData = async (pwd = null) => {
const url = `/api?action=${pwd ? 'verify_password' : 'get_player_data'}${pwd ? '' : '&id=' + encodeURIComponent(streamId)}`;
const opts = pwd ? {
method: 'POST',
body: JSON.stringify({ id: streamId, password: pwd }),
headers: { 'Content-Type': 'application/json' }
} : {};
const res = await fetch(url, opts);
const json = await res.json();
if (json.status !== 'success') throw new Error(t('err.' + (json.code || '')) || json.code || '');
return json.data;
};
const checkPlayerStream = async (password = '') => {
const res = await fetch('/api?action=check_player_stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: streamId, password })
});
const json = await res.json();
if (json.status !== 'success') throw new Error(t('err.' + (json.code || '')) || json.code || t('check_failed'));
return json.data || { valid: false };
};
const waitForLiveAndStart = (data, password = '') => {
clearLivePoll();
destroyPlayer();
if (!data.links || data.links.length === 0) {
setLoadingState(t('no_sources'), '', true);
return;
}
let started = false;
const probeAndStart = async (showChecking = false) => {
if (started) return;
if (showChecking) {
setLoadingState(t('checking'), t('checking_sub'));
}
try {
const result = await checkPlayerStream(password);
if (result.valid) {
started = true;
clearLivePoll();
initPlayer(data, result.url || '', password);
return;
}
setLoadingState(t('no_stream'), t('waiting_sub'), true);
} catch (e) {
setLoadingState(e.message || t('no_stream'), t('waiting_sub'), true);
}
};
probeAndStart(true);
livePollTimer = window.setInterval(() => probeAndStart(false), livePollMs);
};
try {
const data = await fetchData();
applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || '');
if (data.requires_password) {
els.loading.classList.add('hidden');
els.pwdModal.classList.remove('hidden');
document.getElementById('pwd-in').focus();
} else {
startViewerStats();
waitForLiveAndStart(data);
}
} catch (e) {
setLoadingState(e.message, '', true);
}
document.getElementById('password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const pwd = document.getElementById('pwd-in').value;
try {
const data = await fetchData(pwd);
applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || '');
els.pwdModal.classList.add('hidden');
startViewerStats();
waitForLiveAndStart(data, pwd);
} catch (err) {
const errEl = document.getElementById('pwd-err');
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
function startPlaybackMonitor(data, password = '') {
clearPlaybackMonitor();
playbackMonitorTimer = window.setInterval(async () => {
try {
const result = await checkPlayerStream(password);
playbackMisses = result.valid ? 0 : playbackMisses + 1;
} catch (e) {
playbackMisses += 1;
}
if (playbackMisses >= 2) {
setLoadingState(t('no_stream'), t('resuming_sub'), true);
waitForLiveAndStart(data, password);
}
}, playbackMonitorMs);
}
function initPlayer(data, preferredUrl = '', password = '') {
clearLivePoll();
destroyPlayer();
els.loading.classList.add('hidden');
els.loading.classList.remove('waiting-live');
els.player.classList.remove('hidden');
const links = data.links || [];
if (links.length === 0) {
els.player.classList.add('hidden');
els.loading.classList.remove('hidden');
els.msg.textContent = t('no_sources');
return;
}
const activeLinks = [...links];
const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl);
if (preferredIndex > 0) {
activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]);
}
const quality = activeLinks.map((l, i) => ({
default: i === 0,
html: l.name,
url: getPlaybackUrl(l),
type: getLinkType(l)
}));
const initialLink = activeLinks[0] || null;
playerInstance = new Artplayer({
container: '.artplayer-app',
url: quality[0].url,
type: quality[0].type,
quality: quality,
title: data.eventName,
autoplay: true,
isLive: linkUsesShakaDrm(initialLink),
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)) {
if (art.hls) art.hls.destroy();
if (window.shaka?.polyfill) shaka.polyfill.installAll();
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
art.notice.show = t('drm_unavailable');
return;
}
const selectedDrm = selectDrmConfig(currentLink);
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
art.notice.show = t('drm_android_webview_unsupported');
return;
}
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
if (!licenseUrl) {
art.notice.show = t('drm_license_missing');
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 fairplay = selectedDrm?.drmType === 'fairplay';
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
const servers = { [keySystem]: licenseUrl };
const advanced = {};
if (fairplay) {
servers[fairplayLegacyKeySystem] = licenseUrl;
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
};
if (fairplay && !certificateUrl) {
art.notice.show = t('drm_certificate_missing');
return;
}
player.configure({
drm: {
...drmConfig,
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
},
...(fairplay ? { streaming: { useNativeHlsForFairPlay: false } } : {})
});
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 (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.shaka) {
art.shaka.destroy().catch(() => {});
art.shaka = null;
}
if (art.streamhallDurationGuard) {
art.streamhallDurationGuard();
art.streamhallDurationGuard = null;
}
if (art.hls) art.hls.destroy();
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
class CustomLoader extends Hls.DefaultConfig.loader {
loadInternal() {
const { context } = this;
const overrideKey = keyOverride && context && context.keyInfo ? selectHlsKeyOverride(keyOverride, context.url) : '';
if (overrideKey) {
try {
const keyData = hexToArrayBuffer(overrideKey);
this.loader = {
readyState: 4,
status: 200,
statusText: 'OK',
responseType: 'arraybuffer',
response: keyData,
responseText: keyData,
responseURL: context.url
};
this.readystatechange();
return;
} catch (e) {
console.error('Key Override Error:', e);
}
}
super.loadInternal();
}
}
const hls = new Hls({
enableSoftwareAES: !!keyOverride,
loader: keyOverride ? CustomLoader : Hls.DefaultConfig.loader,
debug: false
});
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on('destroy', () => hls.destroy());
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
} else {
art.notice.show = t('hls_unsupported');
}
},
dash: function (video, url, art) {
if (!window.dashjs) {
art.notice.show = t('dash_unavailable');
return;
}
if (art.dash) art.dash.destroy();
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
const dash = dashjs.MediaPlayer().create();
const protectionData = currentLink ? buildClearKeyProtectionData(currentLink.clearkey) : null;
if (protectionData) dash.setProtectionData(protectionData);
dash.initialize(video, url, art.option.autoplay);
art.dash = dash;
art.on('destroy', () => dash.destroy());
}
},
plugins: [
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
]
});
startPlaybackMonitor(data, password);
}
});
</script>
</body>
</html>