Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4c3e6f445 | |||
| 42ce5d2684 | |||
| 6d39c512d7 | |||
| 8e1ed10ba5 | |||
| 8f63f20fdf | |||
| 2b0aaf3a5d | |||
| 601eb0247f | |||
| 34de0bdef4 | |||
| 90fe42a81a |
@@ -5,4 +5,7 @@ __pycache__/
|
||||
.env
|
||||
.DS_Store
|
||||
CHANGELOG.md
|
||||
CODEX_CHANGELOG.md
|
||||
CODEX_TODO.md
|
||||
CODEX_REVIEW.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -28,14 +28,15 @@
|
||||
## ✨ Features
|
||||
|
||||
- **Public stream list** - Live and archive tabs, password-protected streams, custom site branding, bilingual UI (Chinese / English) with per-language site description
|
||||
- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support
|
||||
- **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering
|
||||
- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support; Widevine and FairPlay DRM playback via Shaka Player (multi-DRM configs per source, Android Telegram WebView detection)
|
||||
- **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources with per-source labels and proxy mode selection
|
||||
- **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export
|
||||
- **Telegram notifications** - Per-stream push messages on stream start and stop
|
||||
- **Telegram notifications** - Per-stream push messages on stream start and stop; each source going live fires its own notification, simultaneous go-lives within a configurable window are merged into one message, and brief RTMP reconnects within a grace period are suppressed to avoid spurious stop/start pairs
|
||||
- **Stream push** - Local file browser with per-file and per-folder RTMP push management; multi-file folder push with independent stream keys; inline push status and detail modal; remote RTMP push config for external encoders; hidden HLS route proxy (`/h/<slug>`) so real stream keys are never exposed publicly
|
||||
- **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser
|
||||
- **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback
|
||||
- **HLS proxy modes** - Per-source direct, full proxy, or manifest-only proxy modes for balancing source URL exposure, CORS compatibility, and server bandwidth; full proxy supports upstream cookie forwarding for cookie-authenticated CDNs (e.g. CloudFront signed cookies), with the cookie stored server-side and never exposed in playback URLs
|
||||
- **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints
|
||||
- **Mobile responsive** - Admin panel sidebar, source editor, file browser rows, and push directory sidebar all collapse gracefully on narrow screens
|
||||
|
||||
<div align="right">
|
||||
|
||||
@@ -142,14 +143,17 @@ Set these environment variables in `docker-compose.yml`:
|
||||
|
||||
| Variable | Default | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `SECRET_KEY` | `change-this-secret` | **Yes** | HMAC signing key for sessions and stream routes |
|
||||
| `SECRET_KEY` | `REPLACE_ME` | **Yes** | HMAC signing key for sessions and stream routes. Generate with `openssl rand -hex 32` |
|
||||
| `DATABASE_URL` | see compose file | **Yes** | PostgreSQL connection string |
|
||||
| `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password |
|
||||
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
|
||||
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | No | SRS HTTP playback base URL |
|
||||
| `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe |
|
||||
| `HLS_PROXY_TIMEOUT` | `15` | No | Seconds before aborting an upstream HLS manifest/segment proxy request |
|
||||
| `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks |
|
||||
| `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call |
|
||||
| `TG_RECONNECT_GRACE_SECS` | `60` | No | Grace period before sending a stop notification; absorbs brief RTMP reconnects (`0` disables) |
|
||||
| `TG_START_MERGE_SECS` | `30` | No | Window for merging simultaneous link-online events into one start notification (`0` disables) |
|
||||
| `RTMP_HOST` | `srs` | No | Hostname of the SRS container used for local push jobs |
|
||||
| `VIDEOS_DIRS` | *(unset)* | No | Comma-separated list of directories exposed in the file browser. Optionally prefix each path with a label: `label:/app/path`. Multiple entries: `movies:/app/movies,shows:/app/shows` |
|
||||
|
||||
@@ -182,6 +186,19 @@ volumes:
|
||||
- /your/media/path:/app/media/external
|
||||
```
|
||||
|
||||
**HLS proxy modes**
|
||||
|
||||
Each stream source can choose how external HLS URLs are exposed to viewers:
|
||||
|
||||
| Mode | Behavior | Bandwidth impact |
|
||||
|---|---|---|
|
||||
| `Auto` | Backward-compatible default; external HLS uses the full proxy | StreamHall carries manifest and segment traffic |
|
||||
| `Direct` | Player uses the source URL directly | Viewer traffic goes to the source server |
|
||||
| `Full proxy` | Manifest, segments, maps, and keys are routed through `/proxy/hls/` | StreamHall carries all HLS media traffic |
|
||||
| `Manifest only` | Only the playlist uses StreamHall; segment/key/map URLs are absolute source URLs | Low StreamHall bandwidth; final media URLs remain visible in browser network tools |
|
||||
|
||||
In **Full proxy** mode, a source can also set an **upstream cookie** for CDNs that require cookie-based authentication (e.g. CloudFront signed cookies). StreamHall forwards the cookie on every manifest and segment request. The cookie is stored server-side and referenced via a signed opaque token in proxy URLs, so it is never derivable from a playback or segment URL. Upstream proxy requests reuse pooled HTTP connections to avoid per-request TLS handshake overhead.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
@@ -234,7 +251,7 @@ The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`,
|
||||
### Authentication
|
||||
|
||||
- **Browser session** - cookie set by `POST /api?action=login`
|
||||
- **API key** - send `Authorization: Bearer <token>` header, or append `?api_key=<token>` to any admin endpoint. Keys are managed in the admin panel under **Site Settings → API Keys**.
|
||||
- **API key** - send `Authorization: Bearer <token>` header. Keys are managed in the admin panel under **Site Settings → API Keys**.
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
@@ -280,7 +297,7 @@ The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`,
|
||||
| `GET` | `/api?action=stats_geo` | Country-level visitor counts |
|
||||
| `GET` | `/api?action=stats_stream_detail&id=<id>` | Single-stream detail |
|
||||
| `GET` | `/api?action=stats_sessions_page&id=<id>` | Paginated session list |
|
||||
| `GET` | `/api?action=stats_export_csv` | Export sessions as CSV |
|
||||
| `GET` | `/api?action=stats_export_csv&range=<range>` | Export sessions as CSV (`range`: `today`, `7d`, `30d` (default), `all`) |
|
||||
|
||||
<div align="right">
|
||||
|
||||
|
||||
+24
-7
@@ -28,14 +28,15 @@
|
||||
## ✨ 功能特性
|
||||
|
||||
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介)
|
||||
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey
|
||||
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理
|
||||
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测)
|
||||
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理,支持逐字段标签和代理模式选择
|
||||
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
|
||||
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
|
||||
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知;多视角直播下每个视角上线都会独立推送,时间窗口内同时开播的视角会合并为一条消息,RTMP 短暂断线重连在宽限期内不会误触发关播 / 开播通知
|
||||
- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
|
||||
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
|
||||
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
|
||||
- **HLS 代理模式** - 每个播放源可选择直连、完整代理或仅代理 Manifest,在源地址暴露、跨域兼容和服务器带宽之间自行取舍;完整代理模式支持上游 Cookie 转发,可对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie),Cookie 仅存于服务端、不会暴露在播放地址中
|
||||
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
|
||||
- **移动端适配** - 管理后台侧边栏、视角编辑器、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
|
||||
|
||||
<div align="right">
|
||||
|
||||
@@ -142,14 +143,17 @@ python server.py
|
||||
|
||||
| 变量 | 默认值 | 是否必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `SECRET_KEY` | `change-this-secret` | **必填** | 会话与推流路由的 HMAC 签名密钥 |
|
||||
| `SECRET_KEY` | `REPLACE_ME` | **必填** | 会话与推流路由的 HMAC 签名密钥。可用 `openssl rand -hex 32` 生成 |
|
||||
| `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 |
|
||||
| `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 |
|
||||
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
|
||||
| `SRS_HTTP_ORIGIN` | `http://srs:8080` | 否 | SRS HTTP 播放基础地址 |
|
||||
| `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 |
|
||||
| `HLS_PROXY_TIMEOUT` | `15` | 否 | 上游 HLS manifest / 分片代理请求超时秒数 |
|
||||
| `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 |
|
||||
| `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 |
|
||||
| `TG_RECONNECT_GRACE_SECS` | `60` | 否 | 发送关播通知前的宽限期,用于吸收短暂 RTMP 重连(`0` 关闭) |
|
||||
| `TG_START_MERGE_SECS` | `30` | 否 | 合并同时上线视角为一条开播通知的时间窗口(`0` 关闭) |
|
||||
| `RTMP_HOST` | `srs` | 否 | 本地推流任务使用的 SRS 容器主机名 |
|
||||
| `VIDEOS_DIRS` | *(未设置)* | 否 | 文件浏览器暴露的目录,逗号分隔。可为每个路径加标签前缀:`label:/app/path`。多个示例:`movies:/app/movies,shows:/app/shows` |
|
||||
|
||||
@@ -182,6 +186,19 @@ volumes:
|
||||
- /your/media/path:/app/media/external
|
||||
```
|
||||
|
||||
**HLS 代理模式**
|
||||
|
||||
每个播放源都可以选择外部 HLS 链接如何暴露给观众:
|
||||
|
||||
| 模式 | 行为 | 带宽影响 |
|
||||
|---|---|---|
|
||||
| `自动` | 向后兼容默认行为;外部 HLS 使用完整代理 | StreamHall 承担 manifest 和分片流量 |
|
||||
| `直连` | 播放器直接使用源站 URL | 观众流量走源服务器 |
|
||||
| `完整代理` | manifest、分片、map、key 都通过 `/proxy/hls/` | StreamHall 承担全部 HLS 媒体流量 |
|
||||
| `仅 Manifest` | 只有播放列表经过 StreamHall;分片、key、map 改写为源站绝对地址 | StreamHall 带宽较低;最终媒体 URL 仍会出现在浏览器网络请求中 |
|
||||
|
||||
**完整代理**模式下,播放源还可以设置**上游 Cookie**,用于对接依赖 Cookie 鉴权的 CDN(如 CloudFront 签名 Cookie)。StreamHall 会在每次 manifest 和分片请求时附带该 Cookie。Cookie 仅存于服务端,并以签名后的不可逆 token 形式嵌入代理地址,因此无法从播放或分片 URL 中还原出来。上游代理请求会复用连接池中的持久 HTTP 连接,避免每次请求都重新进行 TLS 握手。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
@@ -234,7 +251,7 @@ RTMP 服务器: rtmp://HOST:1935/live
|
||||
### 鉴权方式
|
||||
|
||||
- **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie
|
||||
- **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`,或在 URL 追加 `?api_key=<token>`。密钥在后台**网站设置 → API 密钥**处管理。
|
||||
- **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`。密钥在后台**网站设置 → API 密钥**处管理。
|
||||
|
||||
### 公开接口
|
||||
|
||||
@@ -280,7 +297,7 @@ RTMP 服务器: rtmp://HOST:1935/live
|
||||
| `GET` | `/api?action=stats_geo` | 地理分布(国家级) |
|
||||
| `GET` | `/api?action=stats_stream_detail&id=<id>` | 单场直播详情 |
|
||||
| `GET` | `/api?action=stats_sessions_page&id=<id>` | 分页会话列表 |
|
||||
| `GET` | `/api?action=stats_export_csv` | 导出会话 CSV |
|
||||
| `GET` | `/api?action=stats_export_csv&range=<range>` | 导出会话 CSV(`range`:`today`、`7d`、`30d`(默认)、`all`) |
|
||||
|
||||
<div align="right">
|
||||
|
||||
|
||||
+9
-3
@@ -10,8 +10,9 @@ services:
|
||||
ports:
|
||||
- "8085:8080"
|
||||
environment:
|
||||
SECRET_KEY: "change-this-secret"
|
||||
DATABASE_URL: "postgresql://streamhall:streamhall_pg_password@postgres:5432/streamhall"
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY: "REPLACE_ME"
|
||||
DATABASE_URL: "postgresql://streamhall:REPLACE_DB_PASSWORD@postgres:5432/streamhall"
|
||||
TZ: "UTC"
|
||||
RTMP_HOST: "srs"
|
||||
# Comma-separated list of video directories (optional label:path format)
|
||||
@@ -21,6 +22,11 @@ services:
|
||||
- ./videos:/app/videos
|
||||
# Mount additional directories as needed:
|
||||
# - /local/path:/app/media/label
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -29,7 +35,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: "streamhall"
|
||||
POSTGRES_USER: "streamhall"
|
||||
POSTGRES_PASSWORD: "streamhall_pg_password"
|
||||
POSTGRES_PASSWORD: "REPLACE_DB_PASSWORD"
|
||||
TZ: "UTC"
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
|
||||
+1058
-81
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -677,6 +677,12 @@
|
||||
#sh-load-more-wrap {
|
||||
text-align: center;
|
||||
padding: 16px 0 24px;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
#sh-load-more-wrap.lm-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
pointer-events: none;
|
||||
}
|
||||
#sh-load-more-btn {
|
||||
border: 1px solid var(--line);
|
||||
@@ -737,7 +743,7 @@
|
||||
<div id="loading-indicator" class="loader"></div>
|
||||
<p id="error-message" class="message hidden"></p>
|
||||
<div id="stream-list"></div>
|
||||
<div id="sh-load-more-wrap" style="display:none;">
|
||||
<div id="sh-load-more-wrap" class="lm-hidden">
|
||||
<button id="sh-load-more-btn"></button>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1097,7 +1103,7 @@
|
||||
if (!streams.length) {
|
||||
errorMessage.textContent = emptyText(activeStreamLabel);
|
||||
errorMessage.classList.remove('hidden');
|
||||
document.getElementById('sh-load-more-wrap').style.display = 'none';
|
||||
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
|
||||
return;
|
||||
}
|
||||
if (_visibleCount === 0) _visibleCount = _calcBatch();
|
||||
@@ -1125,9 +1131,9 @@
|
||||
if (lmWrap && lmBtn) {
|
||||
if (streams.length > _visibleCount) {
|
||||
lmBtn.textContent = t('list.load_more');
|
||||
lmWrap.style.display = '';
|
||||
lmWrap.classList.remove('lm-hidden');
|
||||
} else {
|
||||
lmWrap.style.display = 'none';
|
||||
lmWrap.classList.add('lm-hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1139,6 +1145,7 @@
|
||||
activeStreamLabel = nextLabel;
|
||||
localStorage.setItem('home_stream_label', activeStreamLabel);
|
||||
streamList.classList.add('is-switching');
|
||||
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
|
||||
window.setTimeout(() => {
|
||||
_visibleCount = 0;
|
||||
renderStreams();
|
||||
|
||||
+668
-9
@@ -8,6 +8,7 @@
|
||||
<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>
|
||||
@@ -181,6 +182,11 @@
|
||||
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': '直播不存在',
|
||||
@@ -209,6 +215,11 @@
|
||||
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',
|
||||
@@ -222,7 +233,7 @@
|
||||
},
|
||||
};
|
||||
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh';
|
||||
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || key;
|
||||
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');
|
||||
@@ -324,7 +335,467 @@
|
||||
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';
|
||||
@@ -334,6 +805,8 @@
|
||||
}
|
||||
|
||||
function getPlaybackUrl(link) {
|
||||
const selectedDrm = selectDrmConfig(link);
|
||||
if (selectedDrm?.playbackUrl) return selectedDrm.playback_url || selectedDrm.playbackUrl;
|
||||
return link.playback_url || link.url;
|
||||
}
|
||||
|
||||
@@ -354,6 +827,7 @@
|
||||
let viewerHeartbeatTimer = null;
|
||||
let viewerSessionId = '';
|
||||
let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || '';
|
||||
let viewerToken = '';
|
||||
let playerInstance = null;
|
||||
let playbackMisses = 0;
|
||||
if (!viewerVisitorId) {
|
||||
@@ -431,6 +905,7 @@
|
||||
const data = await postViewerEvent('viewer_start', {
|
||||
id: streamId,
|
||||
visitorId: viewerVisitorId,
|
||||
viewerToken,
|
||||
referer: document.referrer || '',
|
||||
state: viewerState()
|
||||
});
|
||||
@@ -538,6 +1013,7 @@
|
||||
els.pwdModal.classList.remove('hidden');
|
||||
document.getElementById('pwd-in').focus();
|
||||
} else {
|
||||
viewerToken = data.viewerToken || '';
|
||||
startViewerStats();
|
||||
waitForLiveAndStart(data);
|
||||
}
|
||||
@@ -553,6 +1029,7 @@
|
||||
applyPlayerTitle(data);
|
||||
applySiteIcon(data.siteIconUrl || '');
|
||||
els.pwdModal.classList.add('hidden');
|
||||
viewerToken = data.viewerToken || '';
|
||||
startViewerStats();
|
||||
waitForLiveAndStart(data, pwd);
|
||||
} catch (err) {
|
||||
@@ -592,7 +1069,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const activeLinks = [...links];
|
||||
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]);
|
||||
@@ -604,6 +1081,13 @@
|
||||
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',
|
||||
@@ -612,6 +1096,7 @@
|
||||
quality: quality,
|
||||
title: data.eventName,
|
||||
autoplay: true,
|
||||
isLive: data.streamLabel === 'LIVE' && !initialUsesDrm,
|
||||
volume: 0.5,
|
||||
autoSize: true,
|
||||
fullscreen: true,
|
||||
@@ -631,9 +1116,161 @@
|
||||
}
|
||||
},
|
||||
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.hls) art.hls.destroy();
|
||||
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
|
||||
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 {
|
||||
@@ -665,10 +1302,14 @@
|
||||
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')) {
|
||||
@@ -692,11 +1333,29 @@
|
||||
art.on('destroy', () => dash.destroy());
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
artplayerPluginHlsControl({
|
||||
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
|
||||
})
|
||||
]
|
||||
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);
|
||||
}
|
||||
|
||||
+1455
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user