9 Commits

Author SHA1 Message Date
Stardream 8f63f20fdf feat: expand DRM playback and discovery support
Build and Push Docker Image / build (push) Successful in 13s
- native FairPlay HLS playback added for iOS/Safari with same-origin license proxy
- Widevine license proxy added to avoid browser-side CORS license failures
- DRM-specific playback URLs added for per-browser MPD/HLS variants
- admin DRM auto-discovery added for DASH Widevine and HLS FairPlay manifests
- DRM stream probing now checks DRM-specific playback URLs
- Shaka quality selector deduplicates audio variants while preserving same-resolution bitrates
- DRM proxy source and config index matching stabilized after source reordering
- DRM manifest discovery rejects private, loopback, link-local, and redirect targets
- Android Telegram WebView Widevine handling now probes key-system capability before blocking
2026-05-25 02:19:30 +10:00
Stardream 2b0aaf3a5d fix: security hardening and code review improvements
Build and Push Docker Image / build (push) Successful in 12s
- hls proxy now signs full URL instead of hostname only (SSRF)
- viewer token required to start session (prevents stats forgery and password bypass)
- weak SECRET_KEY placeholder replaced with REPLACE_ME, startup warning added
- api key auth drops query-string fallback, Bearer header only
- hls variant playlist DRM detection fetches first variant
- dash manifest DRM detection added
- android webview detection restricted to Telegram only
- privacy comment removed from ip hash (ip is stored in plaintext)
- csv export adds range filter (today/7d/30d/all), defaults to 30d
- analytics export button integrated as dropdown with range selection
- docker-compose placeholder defaults updated
- readme and readme.zh-cn updated for v1.2.0 changes
2026-05-24 01:40:03 +10:00
Stardream 601eb0247f feat: DRM playback with Widevine and FairPlay via Shaka Player
Build and Push Docker Image / build (push) Successful in 14s
2026-05-24 00:18:52 +10:00
Stardream 34de0bdef4 feat: mobile responsive layout for admin panel and file browser 2026-05-24 00:18:37 +10:00
Stardream 90fe42a81a fix: load-more button animates with card list on tab switch
Build and Push Docker Image / build (push) Successful in 23s
Replace display:none toggle with opacity/transform transition so the
button fades and slides in sync with #stream-list.is-switching (0.18s ease).
Tab switch now hides the button at the same time as the list.
2026-05-23 00:02:37 +10:00
Stardream b39533c5b6 fix: push jobs lost on container recreation
Build and Push Docker Image / build (push) Successful in 15s
- resume_push_jobs() waits for SRS RTMP port before launching ffmpeg,
  preventing failed launches when the full stack restarts in parallel
- Run resume in a background thread so HTTP server starts immediately
- Push job records are now only deleted from push_jobs when the user
  explicitly stops a job or the file completes normally (exit 0, non-loop);
  unexpected exits (SRS going down, connection dropped) preserve the record
  so the job is resumed on the next container start
2026-05-22 23:02:34 +10:00
Stardream 5991f39c4f fix: local push view not initializing when navigating from login page
Build and Push Docker Image / build (push) Successful in 11s
- enterPanel() now dispatches admin:enter-local when the active view is local,
  matching the existing admin:enter-dashboard pattern
- DOMContentLoaded block listens for admin:enter-local to call openPushView()
- Initial hash check also falls back to localStorage so a page refresh with
  local view stored also triggers file browser load
2026-05-22 22:03:35 +10:00
Stardream 38cd9dbeaf ci: build and push via internal registry to bypass Cloudflare 413
Build and Push Docker Image / build (push) Successful in 1m31s
2026-05-22 21:35:36 +10:00
Stardream dc949bdeab feat: local push file browser, VOD serving, and admin UX overhaul
Build and Push Docker Image / build (push) Failing after 1m5s
Local push & file browser
- File browser with breadcrumb nav, search, directory memory, .. row,
  and hidden/system folder filtering (./@/#)
- Color-coded file extension tags; file sizes shown inline
- Per-file push modal with random stream key generator and responsive width
- Folder multi-file push modal: independent stream keys per file,
  batch start/stop, inline live-dot status with real-time duration
- Push status inline in file rows replacing top push-jobs area;
  job detail modal with copy/stop/add-stream actions
- /h/<slug> HLS proxy route registered automatically on push start
- Folder push and publish-archive recurse subfolders via os.walk
- "Add to existing stream" dropdown at file, folder, and job modal entries
- Stream editor supports prefilling multiple source links via links array
- list_folder_videos API returns playable files with signed URLs

VOD / video serving
- /video/<token>/<payload> endpoint with HMAC-signed URLs and
  HTTP Range support (206 Partial Content, seek-capable)
- Publish-archive button on file rows and folder rows

Admin UX
- Replace all 18 native alert() with themed Toast notifications
  (success/error/info/warn, 3.5s auto-dismiss, dark mode aware)
- Replace all 3 native confirm() with custom modal (showConfirm)
- Custom overlay scrollbar for admin.html and index.html: no layout
  shift, theme-colored, auto-hides after 1.5s, drag-supported
- background-attachment: fixed on admin and index body backgrounds
- Drag handle for viewport config rows in stream editor
- Pagination and real-time search for hidden push address mapping table
- Pagination for stream analytics detail table with SSE-safe page state
- Stream picker search placeholder i18n
- Lang toggle button title/aria-label i18n
- View URL hash renamed: push -> local, obs -> remote

Index (public) page
- Load more: viewport-aware initial batch calculated from .stream-switch
  bottom position; ghost-style button; card entrance animation with
  50ms per-card stagger on load-more click only

Infrastructure
- Dockerfile: install ffmpeg; separate requirements COPY for layer cache
- docker-compose.yml: add RTMP_HOST, VIDEOS_DIRS env vars, videos volume
- README: document VIDEOS_DIRS mount methods, password reset procedure

Fixes
- action=add 500 error: psycopg3 dict_row does not support int subscript
- Lang toggle button title/aria-label missing i18n keys
- API Keys list not re-rendering on language switch
- Admin stream count per tab showing combined LIVE+ARCHIVE total
- Em dash in api.new_token_hint replaced with hyphen
2026-05-22 21:10:33 +10:00
11 changed files with 5993 additions and 163 deletions
+3 -2
View File
@@ -18,11 +18,12 @@ jobs:
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
run: | run: |
echo "${{ secrets.CR_TOKEN }}" | \ echo "${{ secrets.CR_TOKEN }}" | \
docker login git.stdm.moe -u ${{ github.actor }} --password-stdin docker login 127.0.0.1:3333 -u ${{ github.actor }} --password-stdin
- name: Build and push - name: Build and push
run: | run: |
IMAGE=git.stdm.moe/stardream/streamhall IMAGE=127.0.0.1:3333/stardream/streamhall
PUBLIC=git.stdm.moe/stardream/streamhall
REF="${{ github.ref }}" REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/* ]]; then if [[ "$REF" == refs/tags/* ]]; then
+5
View File
@@ -4,3 +4,8 @@ __pycache__/
*.pyc *.pyc
.env .env
.DS_Store .DS_Store
CHANGELOG.md
CODEX_CHANGELOG.md
CODEX_TODO.md
CODEX_REVIEW.md
AGENTS.md
+5 -1
View File
@@ -6,10 +6,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
DATA_DIR=/app/data DATA_DIR=/app/data
WORKDIR /app WORKDIR /app
COPY . /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE 8080 EXPOSE 8080
+54 -6
View File
@@ -28,13 +28,15 @@
## ✨ Features ## ✨ Features
- **Public stream list** - Live and archive tabs, password-protected streams, custom site branding, bilingual UI (Chinese / English) with per-language site description - **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 - **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; drag-and-drop ordering - **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering
- **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export - **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
- **Stream push** - SRS RTMP publishing helpers, hidden HLS route proxy so real stream keys are never exposed publicly - **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** - Signed `/proxy/hls/` routes for cross-origin HLS playback
- **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints - **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, file browser rows, and push directory sidebar all collapse gracefully on narrow screens
<div align="right"> <div align="right">
@@ -90,6 +92,17 @@ StreamHall initial admin password: <random-password>
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
**Resetting a forgotten admin password**
```bash
docker exec streamhall-postgres psql -U streamhall -d streamhall \
-c "DELETE FROM site_settings WHERE key='admin_password_hash';"
docker restart streamhall
docker logs streamhall
```
Deleting the hash causes StreamHall to generate a new random password on the next startup and print it to the log.
<div align="right"> <div align="right">
[![][back-to-top]](#readme-top) [![][back-to-top]](#readme-top)
@@ -130,7 +143,7 @@ Set these environment variables in `docker-compose.yml`:
| Variable | Default | Required | Description | | 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 | | `DATABASE_URL` | see compose file | **Yes** | PostgreSQL connection string |
| `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password | | `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password |
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` | | `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
@@ -138,10 +151,38 @@ Set these environment variables in `docker-compose.yml`:
| `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe | | `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe |
| `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks | | `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks |
| `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call | | `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call |
| `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` |
> [!WARNING] > [!WARNING]
> Always change `SECRET_KEY` and `POSTGRES_PASSWORD` before exposing StreamHall to a network. The defaults are intentionally weak placeholders. > Always change `SECRET_KEY` and `POSTGRES_PASSWORD` before exposing StreamHall to a network. The defaults are intentionally weak placeholders.
**Mounting video directories for Local Push**
*Method 1 — single base directory, multiple sources as subdirectories:*
Mount multiple host paths under one container base path. The file browser shows them as subdirectories.
```yaml
environment:
VIDEOS_DIRS: "/app/videos"
volumes:
- ./videos:/app/videos/local
- /your/media/path:/app/videos/external
```
*Method 2 — multiple labeled top-level directories:*
Each path is listed as a separate labeled root entry in the file browser.
```yaml
environment:
VIDEOS_DIRS: "/app/videos,external:/app/media/external"
volumes:
- ./videos:/app/videos
- /your/media/path:/app/media/external
```
<div align="right"> <div align="right">
[![][back-to-top]](#readme-top) [![][back-to-top]](#readme-top)
@@ -176,7 +217,9 @@ RTMP server: rtmp://HOST:1935/live
Stream key: your-stream-key Stream key: your-stream-key
``` ```
The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/h/<slug>/...`) that routes through nginx on port `8889`, keeping the real stream key out of the public URL. The admin panel's **Local Push** section provides a file browser for your media directory. Select any video file to push it via RTMP with a custom stream key, or select a folder to push all contained videos simultaneously with independent stream keys. Push status is shown inline per file; a detail modal gives real-time duration, copy addresses, and stop controls.
The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`, keeping the real stream key out of the public URL.
> [!NOTE] > [!NOTE]
> The RTMP host field accepts an optional port, e.g. `live.example.com:1935`. Do not hard-code `:1935` if you use a non-standard port. > The RTMP host field accepts an optional port, e.g. `live.example.com:1935`. Do not hard-code `:1935` if you use a non-standard port.
@@ -192,7 +235,7 @@ The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/
### Authentication ### Authentication
- **Browser session** - cookie set by `POST /api?action=login` - **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 ### Public Endpoints
@@ -202,6 +245,7 @@ The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/
| `GET` | `/api?action=site_settings` | Site title, description, branding | | `GET` | `/api?action=site_settings` | Site title, description, branding |
| `GET` | `/api?action=get_player_data&id=<public_id>` | Player sources and metadata | | `GET` | `/api?action=get_player_data&id=<public_id>` | Player sources and metadata |
| `POST` | `/api?action=verify_password` | Verify a stream password | | `POST` | `/api?action=verify_password` | Verify a stream password |
| `GET` | `/video/<token>/<payload>` | Signed VOD endpoint with HTTP Range support |
### Admin Endpoints ### Admin Endpoints
@@ -222,6 +266,10 @@ The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/
| `GET` | `/api?action=list_api_keys` | List API keys (tokens not returned) | | `GET` | `/api?action=list_api_keys` | List API keys (tokens not returned) |
| `POST` | `/api?action=create_api_key` | Create a key - token returned once | | `POST` | `/api?action=create_api_key` | Create a key - token returned once |
| `POST` | `/api?action=delete_api_key` | Revoke a key by `id` | | `POST` | `/api?action=delete_api_key` | Revoke a key by `id` |
| `GET` | `/api?action=list_pushes` | List active push jobs |
| `POST` | `/api?action=start_push` | Start an RTMP push job for a file |
| `POST` | `/api?action=stop_push` | Stop a push job by stream key |
| `GET` | `/api?action=list_folder_videos` | List playable files in a directory (with signed URLs) |
### Analytics Endpoints ### Analytics Endpoints
@@ -233,7 +281,7 @@ The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/
| `GET` | `/api?action=stats_geo` | Country-level visitor counts | | `GET` | `/api?action=stats_geo` | Country-level visitor counts |
| `GET` | `/api?action=stats_stream_detail&id=<id>` | Single-stream detail | | `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_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"> <div align="right">
+54 -6
View File
@@ -28,13 +28,15 @@
## ✨ 功能特性 ## ✨ 功能特性
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介) - **公开直播列表** - 直播 / 存档双 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 导出 - **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知 - **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
- **推流配置** - SRS RTMP 推流辅助工具,隐藏 HLS 路由代理(公开地址不暴露真实推流码) - **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题 - **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问 - **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
<div align="right"> <div align="right">
@@ -90,6 +92,17 @@ StreamHall initial admin password: <random-password>
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
**重置忘记的管理员密码**
```bash
docker exec streamhall-postgres psql -U streamhall -d streamhall \
-c "DELETE FROM site_settings WHERE key='admin_password_hash';"
docker restart streamhall
docker logs streamhall
```
删除密码哈希后,StreamHall 在下次启动时会重新生成随机密码并打印到日志中。
<div align="right"> <div align="right">
[![][back-to-top]](#readme-top) [![][back-to-top]](#readme-top)
@@ -130,7 +143,7 @@ python server.py
| 变量 | 默认值 | 是否必填 | 说明 | | 变量 | 默认值 | 是否必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `SECRET_KEY` | `change-this-secret` | **必填** | 会话与推流路由的 HMAC 签名密钥 | | `SECRET_KEY` | `REPLACE_ME` | **必填** | 会话与推流路由的 HMAC 签名密钥。可用 `openssl rand -hex 32` 生成 |
| `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 | | `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 |
| `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 | | `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 |
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` | | `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
@@ -138,10 +151,38 @@ python server.py
| `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 | | `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 |
| `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 | | `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 |
| `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 | | `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 |
| `RTMP_HOST` | `srs` | 否 | 本地推流任务使用的 SRS 容器主机名 |
| `VIDEOS_DIRS` | *(未设置)* | 否 | 文件浏览器暴露的目录,逗号分隔。可为每个路径加标签前缀:`label:/app/path`。多个示例:`movies:/app/movies,shows:/app/shows` |
> [!WARNING] > [!WARNING]
> 在将 StreamHall 暴露到网络前,务必修改 `SECRET_KEY` 和 `POSTGRES_PASSWORD`。默认值仅为占位符,安全性极低。 > 在将 StreamHall 暴露到网络前,务必修改 `SECRET_KEY` 和 `POSTGRES_PASSWORD`。默认值仅为占位符,安全性极低。
**挂载本地推流视频目录**
*方式一 - 单一基路径,多个来源作为子目录:*
将多个宿主机路径挂载到同一容器基路径的子目录下,文件浏览器中以子文件夹形式展示。
```yaml
environment:
VIDEOS_DIRS: "/app/videos"
volumes:
- ./videos:/app/videos/local
- /your/media/path:/app/videos/external
```
*方式二 - 多个带标签的顶级目录:*
每个路径在文件浏览器中作为独立的顶级条目显示。
```yaml
environment:
VIDEOS_DIRS: "/app/videos,external:/app/media/external"
volumes:
- ./videos:/app/videos
- /your/media/path:/app/media/external
```
<div align="right"> <div align="right">
[![][back-to-top]](#readme-top) [![][back-to-top]](#readme-top)
@@ -176,7 +217,9 @@ RTMP 服务器: rtmp://HOST:1935/live
推流码: your-stream-key 推流码: your-stream-key
``` ```
管理后台的**推流配置**页面可生成隐藏公开 HLS 地址(`/h/<slug>/...`),通过 Nginx 在 `8889` 端口对外提供访问,真实推流码不会出现在公开 URL 中 管理后台的**本地推流**页面提供媒体目录文件浏览器。选择单个视频文件可使用自定义推流码发起 RTMP 推流;选择文件夹可同时为其中所有视频分别分配独立推流码并批量启动。每个文件行内联显示推流状态,点击"编辑推流"可查看实时时长、复制推流/播放地址和停止操作
隐藏公开 HLS 地址(`/h/<slug>/...`)通过 Nginx 在 `8889` 端口对外提供访问,真实推流码不会出现在公开 URL 中。
> [!NOTE] > [!NOTE]
> RTMP 主机字段支持填写自定义端口,如 `live.example.com:1935`。若使用非标准端口,请勿将 `:1935` 硬编码到地址中。 > RTMP 主机字段支持填写自定义端口,如 `live.example.com:1935`。若使用非标准端口,请勿将 `:1935` 硬编码到地址中。
@@ -192,7 +235,7 @@ RTMP 服务器: rtmp://HOST:1935/live
### 鉴权方式 ### 鉴权方式
- **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie - **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie
- **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`,或在 URL 追加 `?api_key=<token>`。密钥在后台**网站设置 → API 密钥**处管理。 - **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`。密钥在后台**网站设置 → API 密钥**处管理。
### 公开接口 ### 公开接口
@@ -202,6 +245,7 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=site_settings` | 站点标题、简介、品牌设置 | | `GET` | `/api?action=site_settings` | 站点标题、简介、品牌设置 |
| `GET` | `/api?action=get_player_data&id=<public_id>` | 播放器播放源及元数据 | | `GET` | `/api?action=get_player_data&id=<public_id>` | 播放器播放源及元数据 |
| `POST` | `/api?action=verify_password` | 验证直播访问密码 | | `POST` | `/api?action=verify_password` | 验证直播访问密码 |
| `GET` | `/video/<token>/<payload>` | 签名 VOD 点播端点,支持 HTTP Range |
### 管理接口(需鉴权) ### 管理接口(需鉴权)
@@ -222,6 +266,10 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=list_api_keys` | 列出所有 API 密钥(不返回 Token 明文) | | `GET` | `/api?action=list_api_keys` | 列出所有 API 密钥(不返回 Token 明文) |
| `POST` | `/api?action=create_api_key` | 创建密钥(Token 仅返回一次) | | `POST` | `/api?action=create_api_key` | 创建密钥(Token 仅返回一次) |
| `POST` | `/api?action=delete_api_key` | 按 `id` 撤销密钥 | | `POST` | `/api?action=delete_api_key` | 按 `id` 撤销密钥 |
| `GET` | `/api?action=list_pushes` | 列出当前活跃推流任务 |
| `POST` | `/api?action=start_push` | 为指定文件启动 RTMP 推流任务 |
| `POST` | `/api?action=stop_push` | 按推流码停止推流任务 |
| `GET` | `/api?action=list_folder_videos` | 列出目录内可播放文件及签名 URL |
### 统计接口(需鉴权) ### 统计接口(需鉴权)
@@ -233,7 +281,7 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=stats_geo` | 地理分布(国家级) | | `GET` | `/api?action=stats_geo` | 地理分布(国家级) |
| `GET` | `/api?action=stats_stream_detail&id=<id>` | 单场直播详情 | | `GET` | `/api?action=stats_stream_detail&id=<id>` | 单场直播详情 |
| `GET` | `/api?action=stats_sessions_page&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"> <div align="right">
+12 -3
View File
@@ -10,9 +10,18 @@ services:
ports: ports:
- "8085:8080" - "8085:8080"
environment: environment:
SECRET_KEY: "change-this-secret" # Generate with: openssl rand -hex 32
DATABASE_URL: "postgresql://streamhall:streamhall_pg_password@postgres:5432/streamhall" SECRET_KEY: "REPLACE_ME"
DATABASE_URL: "postgresql://streamhall:REPLACE_DB_PASSWORD@postgres:5432/streamhall"
TZ: "UTC" TZ: "UTC"
RTMP_HOST: "srs"
# Comma-separated list of video directories (optional label:path format)
# Example for multiple dirs: "movies:/app/media/movies,anime:/app/media/anime"
VIDEOS_DIRS: "/app/videos"
volumes:
- ./videos:/app/videos
# Mount additional directories as needed:
# - /local/path:/app/media/label
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -21,7 +30,7 @@ services:
environment: environment:
POSTGRES_DB: "streamhall" POSTGRES_DB: "streamhall"
POSTGRES_USER: "streamhall" POSTGRES_USER: "streamhall"
POSTGRES_PASSWORD: "streamhall_pg_password" POSTGRES_PASSWORD: "REPLACE_DB_PASSWORD"
TZ: "UTC" TZ: "UTC"
volumes: volumes:
- ./postgres-data:/var/lib/postgresql/data - ./postgres-data:/var/lib/postgresql/data
+2240 -79
View File
File diff suppressed because it is too large Load Diff
+175 -1
View File
@@ -31,12 +31,60 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
scrollbar-width: none;
}
html::-webkit-scrollbar {
display: none;
}
#sh-scrollbar {
position: fixed;
right: 4px;
top: 0;
bottom: 0;
width: 6px;
z-index: 9990;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
#sh-scrollbar.sh-sb-vis {
pointer-events: auto;
opacity: 1;
}
#sh-scrollbar.dragging {
pointer-events: auto;
}
#sh-scrollbar-thumb {
position: absolute;
right: 0;
width: 6px;
min-height: 40px;
border-radius: 999px;
background: rgba(78, 126, 232, 0.35);
transition: background 0.15s;
cursor: pointer;
user-select: none;
}
#sh-scrollbar-thumb:hover,
#sh-scrollbar.dragging #sh-scrollbar-thumb {
background: rgba(78, 126, 232, 0.65);
}
:root[data-theme="dark"] #sh-scrollbar-thumb {
background: rgba(147, 197, 253, 0.28);
}
:root[data-theme="dark"] #sh-scrollbar-thumb:hover,
:root[data-theme="dark"] #sh-scrollbar.dragging #sh-scrollbar-thumb {
background: rgba(147, 197, 253, 0.6);
}
body { body {
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: background:
linear-gradient(135deg, rgba(246, 249, 255, 0.98), rgba(255, 246, 250, 0.92) 50%, rgba(242, 255, 252, 0.96)), linear-gradient(135deg, rgba(246, 249, 255, 0.98), rgba(255, 246, 250, 0.92) 50%, rgba(242, 255, 252, 0.96)),
linear-gradient(90deg, rgba(78, 126, 232, 0.08), rgba(242, 99, 137, 0.07)); linear-gradient(90deg, rgba(78, 126, 232, 0.08), rgba(242, 99, 137, 0.07));
background-attachment: fixed;
color: var(--text); color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif; font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
overflow-x: hidden; overflow-x: hidden;
@@ -46,6 +94,7 @@
background: background:
linear-gradient(135deg, rgba(15, 20, 34, 0.98), rgba(28, 35, 54, 0.94) 52%, rgba(30, 24, 42, 0.98)), linear-gradient(135deg, rgba(15, 20, 34, 0.98), rgba(28, 35, 54, 0.94) 52%, rgba(30, 24, 42, 0.98)),
linear-gradient(90deg, rgba(25, 184, 177, 0.1), rgba(242, 99, 137, 0.08)); linear-gradient(90deg, rgba(25, 184, 177, 0.1), rgba(242, 99, 137, 0.08));
background-attachment: fixed;
color: #d8deea; color: #d8deea;
} }
@@ -625,6 +674,39 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
#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);
border-radius: 999px;
padding: 8px 28px;
background: transparent;
color: var(--muted);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
#sh-load-more-btn:hover {
color: var(--blue);
border-color: rgba(78, 126, 232, 0.4);
transform: translateY(-1px);
}
@keyframes sh-card-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.card-new {
animation: sh-card-in 0.28s ease-out both;
}
</style> </style>
</head> </head>
@@ -661,6 +743,9 @@
<div id="loading-indicator" class="loader"></div> <div id="loading-indicator" class="loader"></div>
<p id="error-message" class="message hidden"></p> <p id="error-message" class="message hidden"></p>
<div id="stream-list"></div> <div id="stream-list"></div>
<div id="sh-load-more-wrap" class="lm-hidden">
<button id="sh-load-more-btn"></button>
</div>
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
@@ -693,6 +778,7 @@
// empty states // empty states
'empty.live': '当前没有可用的直播', 'empty.live': '当前没有可用的直播',
'empty.archive': '当前没有可用的存档', 'empty.archive': '当前没有可用的存档',
'list.load_more': '加载更多',
// error // error
'err.load': '加载失败', 'err.load': '加载失败',
'err.fetch': '获取直播列表失败', 'err.fetch': '获取直播列表失败',
@@ -723,6 +809,7 @@
// empty states // empty states
'empty.live': 'No live streams available', 'empty.live': 'No live streams available',
'empty.archive': 'No archives available', 'empty.archive': 'No archives available',
'list.load_more': 'Load more',
// error // error
'err.load': 'Failed to load', 'err.load': 'Failed to load',
'err.fetch': 'Failed to fetch streams', 'err.fetch': 'Failed to fetch streams',
@@ -989,6 +1076,18 @@
const labelKicker = (label) => label === 'ARCHIVE' ? t('kicker.archive') : t('kicker.live'); const labelKicker = (label) => label === 'ARCHIVE' ? t('kicker.archive') : t('kicker.live');
let _visibleCount = 0;
const _calcBatch = () => {
const w = window.innerWidth, h = window.innerHeight;
const cols = w > 900 ? 3 : w > 768 ? 2 : 1;
const switchEl = document.querySelector('.stream-switch');
const refBottom = switchEl ? switchEl.getBoundingClientRect().bottom : (w > 768 ? 200 : 160);
const listTop = refBottom + 24;
const rowH = 196;
const rows = Math.max(1, Math.floor((h - listTop - 300) / rowH));
return rows * cols;
};
const renderStreams = () => { const renderStreams = () => {
streamSectionTitle.textContent = labelTitle(activeStreamLabel); streamSectionTitle.textContent = labelTitle(activeStreamLabel);
const kickerEl = document.getElementById('stream-section-kicker'); const kickerEl = document.getElementById('stream-section-kicker');
@@ -1004,9 +1103,12 @@
if (!streams.length) { if (!streams.length) {
errorMessage.textContent = emptyText(activeStreamLabel); errorMessage.textContent = emptyText(activeStreamLabel);
errorMessage.classList.remove('hidden'); errorMessage.classList.remove('hidden');
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
return; return;
} }
streamList.innerHTML = streams.map(stream => { if (_visibleCount === 0) _visibleCount = _calcBatch();
const visStreams = streams.slice(0, _visibleCount);
streamList.innerHTML = visStreams.map(stream => {
const label = normalizeLabel(stream.stream_label); const label = normalizeLabel(stream.stream_label);
return ` return `
<a href="player.html?id=${encodeURIComponent(stream.id)}" class="card"> <a href="player.html?id=${encodeURIComponent(stream.id)}" class="card">
@@ -1024,6 +1126,16 @@
</a> </a>
`; `;
}).join(''); }).join('');
const lmWrap = document.getElementById('sh-load-more-wrap');
const lmBtn = document.getElementById('sh-load-more-btn');
if (lmWrap && lmBtn) {
if (streams.length > _visibleCount) {
lmBtn.textContent = t('list.load_more');
lmWrap.classList.remove('lm-hidden');
} else {
lmWrap.classList.add('lm-hidden');
}
}
}; };
streamSwitchButtons.forEach(btn => { streamSwitchButtons.forEach(btn => {
@@ -1033,13 +1145,27 @@
activeStreamLabel = nextLabel; activeStreamLabel = nextLabel;
localStorage.setItem('home_stream_label', activeStreamLabel); localStorage.setItem('home_stream_label', activeStreamLabel);
streamList.classList.add('is-switching'); streamList.classList.add('is-switching');
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
window.setTimeout(() => { window.setTimeout(() => {
_visibleCount = 0;
renderStreams(); renderStreams();
window.requestAnimationFrame(() => streamList.classList.remove('is-switching')); window.requestAnimationFrame(() => streamList.classList.remove('is-switching'));
}, 140); }, 140);
}); });
}); });
document.getElementById('sh-load-more-btn')?.addEventListener('click', () => {
const oldCount = _visibleCount;
_visibleCount += _calcBatch();
renderStreams();
streamList.querySelectorAll('.card').forEach((card, i) => {
if (i >= oldCount) {
card.classList.add('card-new');
card.style.animationDelay = `${(i - oldCount) * 50}ms`;
}
});
});
try { try {
const settingsResponse = await fetch('/api?action=site_settings'); const settingsResponse = await fetch('/api?action=site_settings');
if (settingsResponse.ok) { if (settingsResponse.ok) {
@@ -1065,6 +1191,54 @@
} }
}); });
</script> </script>
<div id="sh-scrollbar"><div id="sh-scrollbar-thumb"></div></div>
<script>
(function() {
var _sb = document.getElementById('sh-scrollbar');
var _thumb = document.getElementById('sh-scrollbar-thumb');
if (!_sb || !_thumb) return;
var _hideTimer = null, _isDragging = false, _dragStartY = 0, _dragScrollY = 0;
function _h() {
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
return Math.max(40, (vis / total) * vis);
}
function _update() {
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
if (total <= vis) { _sb.classList.remove('sh-sb-vis'); return; }
var h = _h(), top = (window.scrollY / (total - vis)) * (vis - h);
_thumb.style.height = h + 'px';
_thumb.style.top = top + 'px';
}
function _show() {
_update();
if (document.documentElement.scrollHeight <= window.innerHeight) return;
_sb.classList.add('sh-sb-vis');
clearTimeout(_hideTimer);
if (!_isDragging) _hideTimer = setTimeout(function() { _sb.classList.remove('sh-sb-vis'); }, 1500);
}
window.addEventListener('scroll', _show, { passive: true });
window.addEventListener('resize', _update);
document.addEventListener('mousemove', function(e) {
if (e.clientX > window.innerWidth - 20) _show();
});
_thumb.addEventListener('mousedown', function(e) {
_isDragging = true; _dragStartY = e.clientY; _dragScrollY = window.scrollY;
_sb.classList.add('dragging'); clearTimeout(_hideTimer); e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!_isDragging) return;
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
var ratio = (total - vis) / (vis - _h());
window.scrollTo(0, _dragScrollY + (e.clientY - _dragStartY) * ratio);
});
document.addEventListener('mouseup', function() {
if (!_isDragging) return;
_isDragging = false; _sb.classList.remove('dragging');
_hideTimer = setTimeout(function() { _sb.classList.remove('sh-sb-vis'); }, 1500);
});
_update();
})();
</script>
</body> </body>
</html> </html>
+642 -9
View File
@@ -8,6 +8,7 @@
<script src="/vendor/hls.min.js"></script> <script src="/vendor/hls.min.js"></script>
<script src="/vendor/flv.min.js"></script> <script src="/vendor/flv.min.js"></script>
<script src="/vendor/dash.all.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.js"></script>
<script src="/vendor/artplayer-plugin-hls-control.js"></script> <script src="/vendor/artplayer-plugin-hls-control.js"></script>
<style> <style>
@@ -181,6 +182,11 @@
flv_unsupported: '当前浏览器不支持 FLV 播放', flv_unsupported: '当前浏览器不支持 FLV 播放',
hls_unsupported: '当前浏览器不支持 HLS 播放', hls_unsupported: '当前浏览器不支持 HLS 播放',
dash_unavailable: 'DASH 播放组件未加载', 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: '画质', quality: '画质',
// API error codes from server // API error codes from server
'err.stream_not_found': '直播不存在', 'err.stream_not_found': '直播不存在',
@@ -209,6 +215,11 @@
flv_unsupported: 'FLV playback is not supported in this browser', flv_unsupported: 'FLV playback is not supported in this browser',
hls_unsupported: 'HLS playback is not supported in this browser', hls_unsupported: 'HLS playback is not supported in this browser',
dash_unavailable: 'DASH player component not loaded', 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', quality: 'Quality',
// API error codes from server // API error codes from server
'err.stream_not_found': 'Stream not found', 'err.stream_not_found': 'Stream not found',
@@ -222,7 +233,7 @@
}, },
}; };
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh'; 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. // Initialise static text in the HTML that cannot use data-i18n.
document.getElementById('msg').textContent = t('loading'); document.getElementById('msg').textContent = t('loading');
@@ -324,7 +335,467 @@
return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null; 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) { 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; if (link.type) return link.type;
const path = (link.url || '').split('?')[0].toLowerCase(); const path = (link.url || '').split('?')[0].toLowerCase();
if (path.endsWith('.mpd')) return 'dash'; if (path.endsWith('.mpd')) return 'dash';
@@ -334,6 +805,8 @@
} }
function getPlaybackUrl(link) { function getPlaybackUrl(link) {
const selectedDrm = selectDrmConfig(link);
if (selectedDrm?.playbackUrl) return selectedDrm.playback_url || selectedDrm.playbackUrl;
return link.playback_url || link.url; return link.playback_url || link.url;
} }
@@ -354,6 +827,7 @@
let viewerHeartbeatTimer = null; let viewerHeartbeatTimer = null;
let viewerSessionId = ''; let viewerSessionId = '';
let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || ''; let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || '';
let viewerToken = '';
let playerInstance = null; let playerInstance = null;
let playbackMisses = 0; let playbackMisses = 0;
if (!viewerVisitorId) { if (!viewerVisitorId) {
@@ -431,6 +905,7 @@
const data = await postViewerEvent('viewer_start', { const data = await postViewerEvent('viewer_start', {
id: streamId, id: streamId,
visitorId: viewerVisitorId, visitorId: viewerVisitorId,
viewerToken,
referer: document.referrer || '', referer: document.referrer || '',
state: viewerState() state: viewerState()
}); });
@@ -538,6 +1013,7 @@
els.pwdModal.classList.remove('hidden'); els.pwdModal.classList.remove('hidden');
document.getElementById('pwd-in').focus(); document.getElementById('pwd-in').focus();
} else { } else {
viewerToken = data.viewerToken || '';
startViewerStats(); startViewerStats();
waitForLiveAndStart(data); waitForLiveAndStart(data);
} }
@@ -553,6 +1029,7 @@
applyPlayerTitle(data); applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || ''); applySiteIcon(data.siteIconUrl || '');
els.pwdModal.classList.add('hidden'); els.pwdModal.classList.add('hidden');
viewerToken = data.viewerToken || '';
startViewerStats(); startViewerStats();
waitForLiveAndStart(data, pwd); waitForLiveAndStart(data, pwd);
} catch (err) { } catch (err) {
@@ -592,7 +1069,7 @@
return; return;
} }
const activeLinks = [...links]; const activeLinks = links.map((link, index) => ({ ...link, _sourceIndex: index }));
const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl); const preferredIndex = activeLinks.findIndex(link => link.url === preferredUrl || getPlaybackUrl(link) === preferredUrl);
if (preferredIndex > 0) { if (preferredIndex > 0) {
activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]); activeLinks.unshift(activeLinks.splice(preferredIndex, 1)[0]);
@@ -604,6 +1081,13 @@
url: getPlaybackUrl(l), url: getPlaybackUrl(l),
type: getLinkType(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({ playerInstance = new Artplayer({
container: '.artplayer-app', container: '.artplayer-app',
@@ -612,6 +1096,7 @@
quality: quality, quality: quality,
title: data.eventName, title: data.eventName,
autoplay: true, autoplay: true,
isLive: data.streamLabel === 'LIVE' && !initialUsesDrm,
volume: 0.5, volume: 0.5,
autoSize: true, autoSize: true,
fullscreen: true, fullscreen: true,
@@ -631,9 +1116,161 @@
} }
}, },
m3u8: function (video, url, art) { m3u8: function (video, url, art) {
if (Hls.isSupported()) {
if (art.hls) art.hls.destroy();
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url); const 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; const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
class CustomLoader extends Hls.DefaultConfig.loader { class CustomLoader extends Hls.DefaultConfig.loader {
@@ -692,11 +1329,7 @@
art.on('destroy', () => dash.destroy()); art.on('destroy', () => dash.destroy());
} }
}, },
plugins: [ plugins: hlsControlPlugins
artplayerPluginHlsControl({
quality: { control: true, setting: true, getName: (l) => l.height + 'P', title: t('quality') }
})
]
}); });
startPlaybackMonitor(data, password); startPlaybackMonitor(data, password);
} }
+1455
View File
File diff suppressed because it is too large Load Diff
+1315 -23
View File
File diff suppressed because it is too large Load Diff