Compare commits

...
10 Commits
Author SHA1 Message Date
adminandClaude Opus 4.8 8e61dc111c feat: keyboard seek, playback speed, and persist edited settings
Player
- Arrow keys seek by 5s, Shift for a 30s jump on long recordings, J/L for +-30s,
  Space toggles playback, and up/down adjust volume. Dragging a scrubber across a
  multi-hour replay is unusable — one pixel is tens of seconds — so keys are the
  practical way to move around a long recording.
- The shortcuts ignore key events from inputs, so typing a chapter time in the
  replay editor no longer scrubs the video.
- Add a playback speed selector. It is a replay tool: speeding a live stream up
  would only push it further ahead of the encoder's buffer.

Replay editor
- Stop playback restarting on every edit. The player's source effect depended on
  the `lines` and `qualities` arrays, and callers such as the replay editor pass
  fresh literals on every render, so adding a chapter or typing a title was
  treated as a source change and reloaded the stream from the beginning. The
  effect now depends on the resolved source URL instead of the arrays it came
  from. Measured: adding three chapters leaves the media requests unchanged and
  the playhead advancing.

Settings persistence
- Settings edited in the UI were applied to the in-memory settings object and
  never written down, so the brand, transcode ladder and load-balancing strategy
  silently reverted to the config.py defaults on every restart. They are now
  saved to backend/settings.json (atomic temp-file replace) and re-applied at
  startup, with only known keys accepted so a hand-edited file cannot inject
  arbitrary attributes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-29 12:44:33 +08:00
adminandClaude Opus 4.8 cdf591f933 fix: serve replay media from nginx and unbreak settings saves
Replay
- Hand the replay file to nginx with X-Accel-Redirect instead of streaming it
  through the backend. A replay is tens of gigabytes, so Python was copying the
  whole file and holding a worker for the entire transfer: measured 8 MB in
  8.46s (~1 MB/s). nginx serves it from disk with sendfile at ~400 MB/s (1 GB in
  2.68s) and the backend process does not appear in CPU usage during the transfer.
  Authentication and the path-traversal check still run in Python; only the bytes
  moved. The internal location is what keeps the path from being reachable
  directly and bypassing that check.
- nginx reloads and the media directory is aliased internally.

Settings
- GET /settings/transcode-profiles returned the bare mapping while the settings
  page reads `data.profiles`, so it always saw undefined, fell back to an empty
  object, and every save posted `{"profiles": {}}` — which the PUT rejects as
  "at least one profile". Saving settings could never succeed. The response now
  matches the shape PUT accepts, verified by round-tripping it.

Auth
- Derive the admin token instead of storing it. The token is
  sha256(password + ":token"), but it was kept in a module-level set, which only
  works with one process: with uvicorn --workers N, logging in on one worker left
  the others rejecting the token and the admin appeared logged out at random.

Viewer
- Show the real session id in the stats panel; the previous code assigned it
  from a local variable that was never populated, so it always read "不可用".
- Send the first heartbeat before the first command poll. Both fired together on
  mount, so the poll reached the backend before the session existed and 404'd on
  every page load.

Housekeeping
- Ignore backend/nodes.json; the admin node manager writes it at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-29 11:47:52 +08:00
adminandClaude Opus 4.8 4ee945ca0d feat: encode on the GPU and restart individual renditions
Hardware encoding
- Detect the best usable encoder at startup by encoding a test frame with each
  candidate in turn (nvenc, qsv, amf, then libx264). Being compiled into ffmpeg
  is not enough: h264_amf fails without amfrt64.dll and h264_qsv fails without an
  Intel iGPU, so the probe is the only reliable answer.
- Translate the quality target into the chosen encoder's own vocabulary. libx264's
  -crf and "ultrafast" mean nothing to h264_nvenc, which takes -cq and p1..p7, so
  passing the configured values straight through would have been silently wrong.
- On the reference GTX 1060 a 1080p->720p rendition costs ~62% of a core on NVENC
  versus ~109% on libx264 ultrafast.

Per-rendition supervision
- Track encoder processes per rendition, not per stream. is_running answered
  "any alive", so when the source ("原画") encoder died while 1080p and 720p kept
  running, the supervisor saw a healthy stream and never restarted it — the
  source URL then 404'd permanently. It now requires every rendition to be alive
  and reports which ones are down.
- Restart only the dead renditions, so a single failure no longer interrupts
  viewers on the renditions that were fine.

Production serving
- Serve the built frontend from FastAPI with SPA fallback, so production is one
  process and one port with no Node runtime. Node becomes a build-time dependency.
- start.ps1/start.sh build the frontend on first run instead of failing to a 503.

Cleanup
- Drop the duplicated top-left LIVE badge; the control strip already carries it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-27 18:56:21 +08:00
adminandClaude Opus 4.8 996e3fea39 feat: consolidate on the shadcn frontend and harden playback
Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
  keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
  one router and one design system to maintain.

Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
  single 4s segment, so playback ran at up to 1.10x and the forward buffer
  bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
  buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
  hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
  the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
  gesture that restored sound also toggled playback off. One owner now.

Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
  transcoded ones, so a source option exists even when every ladder entry
  downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
  MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.

Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
  never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
  left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
  JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
  and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
  and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
  httpx client open for the streamed body.

Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
  .venv; remove generated screenshots, load test scripts and stray files.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-22 18:07:56 +08:00
adminandClaude Fable 5 186e32caf1 feat: improve replay workflow and viewer UI
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-30 15:35:28 +08:00
admin 57b00affbd feat: add Windows edge node launcher 2026-08-20 10:37:31 +08:00
admin da01967c2e feat: add Windows-first deployment flow 2026-08-20 10:21:44 +08:00
admin 7353d140b8 fix: restore admin sidebar routing 2026-08-20 09:56:53 +08:00
admin 1e87be31da fix: keep dashboard aggregate metrics visible 2026-08-20 09:47:39 +08:00
admin 38824a0055 fix: render metrics and smooth route transitions 2026-08-20 09:45:49 +08:00
82 changed files with 5860 additions and 1954 deletions

No files matched your search

+25 -2
View File
@@ -1,15 +1,38 @@
# dependencies and build output
node_modules/
dist/
__pycache__/
*.pyc
*.tsbuildinfo
# local state and secrets
*.db
venv/
.venv/
*.log
mediamtx/mediamtx.tar.gz
# OSS credentials (accessKeySecret lives here) — never commit
RainOSS
RainOSS.bak
# generated at runtime
runtime/
media/
.playwright-mcp/
*.crt
*.key
# written by the admin node manager, not part of the source
backend/nodes.json
# administrator-edited settings (brand, transcode ladder) saved at runtime
backend/settings.json
# MediaMTX binaries and archives are fetched, not committed
mediamtx/mediamtx
mediamtx/mediamtx.exe
mediamtx/mediamtx.tar.gz
# lockfiles are intentionally not tracked
package-lock.json
# edge-agent
edge-agent/dist/
edge-agent/mediamtx/
+118 -14
View File
@@ -1,20 +1,124 @@
# Deployment
All runtime paths are resolved relative to the repository by default and can be overridden with environment variables.
The maintained deployment target for this project is Windows first. Linux and macOS can still run services manually, but real end-to-end deployment testing should use the PowerShell flow below.
- `LS_DATABASE_URL`: SQLAlchemy database URL.
- `LS_REPLAY_MEDIA_DIR`: server-local directory containing replay files.
Runtime paths are resolved relative to the repository by default and can be overridden with environment variables.
- LS_DATABASE_URL: SQLAlchemy database URL.
- LS_REPLAY_MEDIA_DIR: server-local directory containing replay files. When started by start.ps1, the default is runtime\replay under the repository.
- LS_HLS_RUNTIME_DIR: backend and FFmpeg HLS output directory. When started by start.ps1, the default is runtime\hls under the repository.
## Windows deployment
Run these commands from PowerShell on the Windows node.
### 1. Install prerequisites
Required on PATH: Python 3.11+, FFmpeg, and MediaMTX. MediaMTX may instead be left in the repository's `mediamtx\` directory, which is where the backend looks second. Node.js is only needed to build the frontend for deployment.
### 2. Install application dependencies
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1 -InstallDeps
```
### 3. Build the frontend
Once per deployment, and again whenever the frontend changes. The build output is
committed to the server by the build step and then served by the backend itself,
so **Node.js is a build-time dependency only** — production runs without it and
without a Vite dev server.
```powershell
cd frontend
npm install
npm run build
cd ..
```
Skipping this is not silent: the backend answers every page with HTTP 503 and
`s Frontend not built` until `frontend\dist` exists.
### 4. Start the stack
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
This starts one process on 0.0.0.0:8080 that owns everything:
- **The frontend** is served from `frontend\dist` on the same port. Unknown paths
return the app shell so client-side routes survive a refresh.
- **MediaMTX** is started at boot if nothing is already answering its API on :9997, and restarted by the supervisor if it dies. Set `LS_MEDIA_ORIGIN_AUTOSTART=false` to manage it externally; the backend then only supervises transcoders.
- **Transcoders** are started for every live stream whose MediaMTX path is publishing, and restarted within ~10s of an ffmpeg process exiting. They encode on the GPU when one is available (see below).
- **HLS** is written under `runtime\hls` and served through `GET /hls/...`, which falls back to proxying MediaMTX's own HLS when the local files are absent.
Open http://127.0.0.1:8080/ for the site and /docs for the API. Publish RTMP to
rtmp://127.0.0.1:1935/live/<stream_key>.
### Hardware encoding
The transcoders detect the best encoder the host actually has, at startup, by
encoding a test frame with each candidate in turn:
1. `h264_nvenc` / `hevc_nvenc` (NVIDIA)
2. `h264_qsv` (Intel iGPU)
3. `h264_amf` (AMD)
4. `libx264` (CPU fallback)
Only encoders that really work are used — being compiled into ffmpeg is not
enough, since `h264_amf` fails without `amfrt64.dll` and `h264_qsv` fails without
an Intel iGPU. The choice is logged at startup as `[transcoder] hardware encoder:
…`, or `[transcoder] no working hardware encoder; falling back to libx264` with
the probed candidates listed. On the reference GTX 1060, one 1080p→720p
rendition costs ~62% of a core on NVENC versus ~109% on libx264 ultrafast.
### 5. Useful variants
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1 -HostAddress 0.0.0.0 -BackendPort 8080
```
### 6. Smoke test commands
```powershell
curl.exe http://127.0.0.1:8080/api/health
curl.exe http://127.0.0.1:9997/v3/paths/list
ffmpeg -version
mediamtx --version
nvidia-smi
```
If browser or network tests run from another machine, open the Windows firewall for ports 8080, 1935, and 8888.
## Windows edge node launcher
Build and start an edge node from a Windows edge machine:
```powershell
powershell -ExecutionPolicy Bypass -File .\edge-agent\start-edge.ps1 -Build -OriginUrl http://<origin-host>:8080 -NodeId edge-win-01 -Region default -HlsPort 8889 -ApiPort 9997
```
For normal subsequent starts, omit `-Build`:
```powershell
powershell -ExecutionPolicy Bypass -File .\edge-agent\start-edge.ps1 -OriginUrl http://<origin-host>:8080 -NodeId edge-win-01 -Region default -HlsPort 8889 -ApiPort 9997
```
The launcher sets the StreamCore edge environment variables, starts the Windows edge-agent executable, stores runtime data under `ProgramData\StreamCore\edge\<node-id>` by default, and writes logs to that node data directory. The edge agent then manages its embedded MediaMTX edge process.
## Edge node scripts
```cmd
mediamtx\scripts\start_origin.bat
mediamtx\scripts\start_edge.bat 1 127.0.0.1
mediamtx\scripts\check_multicast.bat
```
## Replay file safety
Replay records store paths relative to LS_REPLAY_MEDIA_DIR. Absolute paths and parent-directory traversal are rejected.
## Linux and macOS
Run `./start.sh`. The script resolves its own directory and does not depend on `/workspace`.
## Windows
1. Create a virtual environment with `py -m venv backend\.venv`.
2. Install dependencies with `backend\.venv\Scripts\pip install -r backend\requirements.txt`.
3. Optionally set `LS_REPLAY_MEDIA_DIR` to an absolute media directory.
4. Run `powershell -ExecutionPolicy Bypass -File .\start.ps1`.
5. In another terminal, run `npm install` and `npm run dev` from `frontend`.
Replay records store paths relative to `LS_REPLAY_MEDIA_DIR`. Absolute paths and parent-directory traversal are rejected.
Linux and macOS are not the primary deployment path. For local development, run the backend with uvicorn from `backend`, run the frontend with `npm run dev` from `frontend`, and set `LS_HLS_RUNTIME_DIR` to a writable directory.
+25 -4
View File
@@ -10,13 +10,23 @@ DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True)
class Settings(BaseSettings):
database_url: str = f"sqlite+aiosqlite:///{(DEFAULT_DATA_DIR / 'streamcore.db').as_posix()}"
admin_password: str = "admin123"
admin_password: str = "NewVision_20061010"
media_origin_host: str = "127.0.0.1"
media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
# MediaMTX is supervised by the backend: started at boot if the origin is not
# already answering, and restarted if it dies. Set LS_MEDIA_ORIGIN_AUTOSTART=false
# to manage it externally (e.g. systemd, or an edge node's own launcher).
media_origin_autostart: bool = True
# "mediamtx" resolves via PATH; the repo-local copies are the fallback so a
# checkout works without a system-wide install. On Windows the binary sits in
# mediamtx\, on Linux/macOS it is usually a bare `mediamtx` on PATH.
media_origin_binary: str = "mediamtx"
media_origin_config: str = str(APP_DIR.parent / "mediamtx" / "mediamtx.yml")
replay_media_dir: str = str(APP_DIR.parent / "media")
hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls")
hls_segment_seconds: int = 4
edge_nodes: list[dict] = [
@@ -42,6 +52,17 @@ class Settings(BaseSettings):
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
]
# Extra rendition that is published alongside the configured ones: the
# publisher's own video+audio stream remuxed into HLS without re-encoding.
# It costs almost no CPU (stream copy) and gives viewers a lossless
# "原画" (source) option even when every configured rendition downsizes.
source_rendition_label: str = "原画"
source_rendition_enabled: bool = True
# On-disk/URL name for that rendition. It IS the HLS path segment, and MediaMTX
# rejects any path containing non-alphanumeric characters, so the Chinese
# display label above cannot be used here — a CI would 500 trying to serve it.
source_rendition_slug: str = "source"
transcode_profiles: dict[str, list[dict]] = {
"standard": qualities,
"bandwidth-saver": [
@@ -55,9 +76,9 @@ class Settings(BaseSettings):
multicast_address_base: str = "239.0.0.1"
multicast_port_base: int = 5000
brand_name: str = "StreamCore"
brand_tagline: str = "Enterprise Streaming Platform"
brand_logo_url: str = ""
brand_name: str = "柳高新视角媒体平台"
brand_tagline: str = "New Vision Media Platform"
brand_logo_url: str = "https://yhlstatic.cn-nb1.rains3.com/Image_1790648201679_296.png"
model_config = {"env_prefix": "LS_"}
+1
View File
@@ -23,6 +23,7 @@ async def init_db():
if settings.database_url.startswith("sqlite"):
columns = {
"replay_file": "VARCHAR(500)",
"replay_url": "VARCHAR(1000)",
"chapters_json": "TEXT",
"transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'",
"renditions_json": "TEXT",
+225 -22
View File
@@ -8,35 +8,93 @@ from starlette.background import BackgroundTask
import httpx
import asyncio
import time
import traceback
import psutil
from database import init_db
from database import init_db, session_factory
from services.transcoder import transcode_mgr
from services.stream_service import StreamService
from services.media_service import media_service
from services.mediamtx_manager import mediamtx_mgr
from models.stream import StreamStatus
from routes import streams_router, nodes_router, auth_router
from routes.settings_route import router as settings_router
from config import settings
from services.load_balancer import lb
from services.node_store import load_nodes
from services.settings_store import load_settings
async def monitor_origin():
previous = psutil.net_io_counters()
previous_time = time.monotonic()
while True:
await asyncio.sleep(10)
async def _origin_paths() -> dict[str, dict]:
"""Fetch MediaMTX path list keyed by path name. Empty dict when origin is unreachable."""
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(
f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list"
)
response.raise_for_status()
return {item.get("name"): item for item in response.json().get("items", [])}
except Exception:
return {}
async def supervise_transcoders() -> list[str]:
"""Restart transcoders that died or drifted from the stream's config.
A live stream whose upstream is publishing (MediaMTX path ready) but whose
local ffmpeg processes have exited would otherwise stay black forever: the
HLS output stops updating and nothing re-triggers a start.
Returns the stream keys that were (re)started this tick.
"""
restarted: list[str] = []
paths = await _origin_paths()
if not paths:
return restarted
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
if stream.status != StreamStatus.live:
continue
path = paths.get(stream.stream_key) or paths.get(f"live/{stream.stream_key}")
# Only act when the publisher is actually connected: restarting against
# a missing source just burns ffmpeg processes that exit immediately.
if not path or not path.get("ready"):
continue
# Must match what start_transcode is given below: needs_restart
# compares against the signature recorded at the last start, so
# passing a different list here would restart the encoders every tick.
qualities = service.transcode_renditions(stream)
if transcode_mgr.needs_restart(stream.stream_key, qualities):
# Report which ones were down: a whole-stream "restarted" message
# hides the case that matters most, one rendition dying while its
# siblings keep running (a dead 原画 was invisible this way).
dead = transcode_mgr.dead_renditions(stream.stream_key)
started = transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
qualities,
)
restarted.append(f"{stream.stream_key} ({started} up, was down: {', '.join(dead)})")
return restarted
async def _metrics_tick(previous, previous_time):
current = psutil.net_io_counters()
current_time = time.monotonic()
elapsed = current_time - previous_time
network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None
network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None
media_available = False
viewer_count = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list")
response.raise_for_status()
payload = response.json()
viewer_count = sum(item.get("readers", 0) for item in payload.get("items", []))
media_available = True
# MediaMTX reports readers as an array of reader objects; there is no
# readersCount field in the version this project ships. Summing the
# array directly raised TypeError as soon as anyone was watching,
# which the bare except below turned into a permanent "no viewers".
viewer_count = sum(len(item.get("readers") or []) for item in payload.get("items", []))
except Exception:
pass
lb.report_health("default", {
@@ -52,18 +110,72 @@ async def monitor_origin():
"packet_loss_indicators": None,
"media_metrics_available": False,
})
previous = current
previous_time = current_time
return current, current_time
async def monitor_origin():
"""Background supervisor loop.
Every step is individually guarded: this task is created with create_task, so
an exception escaping it would silently kill supervision forever with no
traceback reaching the logs. Each phase catches its own errors instead.
"""
previous = psutil.net_io_counters()
previous_time = time.monotonic()
while True:
await asyncio.sleep(10)
try:
previous, previous_time = await _metrics_tick(previous, previous_time)
except Exception:
traceback.print_exc()
try:
# MediaMTX first: the transcoder pass below only acts on a stream whose
# MediaMTX path is ready, so a dead origin would otherwise be reported
# as "nothing to do" forever.
if await mediamtx_mgr.ensure_running():
await mediamtx_mgr.wait_until_ready()
except asyncio.CancelledError:
raise
except Exception:
traceback.print_exc()
try:
restarted = await supervise_transcoders()
for stream_key in restarted:
print(f"[supervisor] restarted transcoder for {stream_key}", flush=True)
except asyncio.CancelledError:
raise
except Exception:
traceback.print_exc()
@asynccontextmanager
async def lifespan(app: FastAPI):
load_nodes()
# Must run before anything reads settings: it applies the administrator's saved
# overrides (brand, transcode ladder) on top of the config defaults.
load_settings()
await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True)
# Bring the media origin up before the transcoders: they immediately open RTMP
# connections to it, and against a dead origin every ffmpeg exits at once.
if settings.media_origin_autostart and not await mediamtx_mgr.is_healthy():
mediamtx_mgr.start()
if not await mediamtx_mgr.wait_until_ready():
print("[supervisor] MediaMTX not ready yet; it will be retried", flush=True)
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
if stream.status == StreamStatus.live:
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
service.transcode_renditions(stream),
)
origin_monitor = asyncio.create_task(monitor_origin())
yield
origin_monitor.cancel()
transcode_mgr.stop_all()
mediamtx_mgr.stop()
app = FastAPI(title="ProgramCore", lifespan=lifespan)
@@ -93,22 +205,113 @@ async def health():
@app.get("/hls/{path:path}")
async def serve_hls(path: str):
hls_root = "/tmp/hls"
hls_root = settings.hls_runtime_dir
fs_path = os.path.join(hls_root, path)
if os.path.isfile(fs_path):
return FileResponse(fs_path)
target = f"http://127.0.0.1:{settings.media_origin_hls_port}/{path}"
# MediaMTX gates HLS behind a "cookieCheck" round trip: it answers with a 302 to
# "<path>?cookieCheck=1" and sets a *Secure* cookie. Over plain HTTP (any
# non-TLS deployment, and every dev machine) browsers refuse to store a Secure
# cookie, so the redirect never terminates and the player loops until it gives
# up. The browser should never see that handshake at all, so complete it here
# and hand back only the final playlist.
#
# The client also has to outlive this function: the body is streamed after we
# return, so an `async with` would close the pool before the first chunk.
client = httpx.AsyncClient(timeout=httpx.Timeout(10), follow_redirects=True)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
req = client.build_request("GET", target)
resp = await client.send(req, stream=True)
from starlette.responses import StreamingResponse
return StreamingResponse(
resp.aiter_bytes(),
status_code=resp.status_code,
headers=dict(resp.headers),
media_type=resp.headers.get("content-type"),
)
request = client.build_request("GET", target)
response = await client.send(request, stream=True)
except Exception:
await client.aclose()
raise HTTPException(status_code=404)
from starlette.responses import StreamingResponse
headers = {
key: value
for key, value in response.headers.items()
# Length/transfer headers describe the origin's framing, not ours, and
# hop-by-hop headers must not be relayed.
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
async def relay():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=response.status_code,
headers=headers,
media_type=response.headers.get("content-type"),
)
from starlette.responses import StreamingResponse
headers = {
key: value
for key, value in response.headers.items()
# Length/transfer headers describe the origin's framing, not ours, and
# hop-by-hop headers must not be relayed.
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
# MediaMTX answers HLS requests with a 302 to "<path>?cookieCheck=1" so the
# player picks up its session cookie. That Location is relative to the origin
# root, so relaying it unchanged sends the browser to /<path> on the backend
# host — not an /hls route, hence a 404 instead of a playlist. Re-anchor it.
location = response.headers.get("location")
if response.is_redirect and location:
parts = urlsplit(location)
if parts.path:
headers["location"] = f"/hls{parts.path}" + (f"?{parts.query}" if parts.query else "")
async def relay():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=response.status_code,
headers=headers,
media_type=response.headers.get("content-type"),
)
# The built frontend is served by this process, so production is one command and
# one port — no Node runtime on the server. Declared last on purpose: a catch-all
# would otherwise shadow every API and /hls route registered above it.
#
# The app uses client-side routing (/watch/1, /admin/streams/…), so requests that
# match no file must return index.html rather than a 404, or a page refresh lands
# on "not found" instead of the route the user was on.
_FRONTEND_DIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "frontend", "dist")
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str):
if full_path.startswith(("api/", "hls/", "docs", "redoc", "openapi.json")):
raise HTTPException(status_code=404)
dist = os.path.abspath(_FRONTEND_DIST)
index = os.path.join(dist, "index.html")
if not os.path.isfile(index):
raise HTTPException(
status_code=503,
detail="前端尚未构建:请先在 frontend 目录执行 npm run build",
)
# Resolve inside dist only; a crafted path must not escape it.
candidate = os.path.abspath(os.path.join(dist, full_path))
if full_path and candidate.startswith(dist) and os.path.isfile(candidate):
return FileResponse(candidate)
return FileResponse(index)
+1
View File
@@ -21,6 +21,7 @@ class Stream(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True)
transcode_profile: Mapped[str] = mapped_column(String(64), default="standard")
renditions_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+22 -8
View File
@@ -1,12 +1,11 @@
import hashlib
import hmac
from fastapi import APIRouter, HTTPException, Depends, Header
from pydantic import BaseModel
from config import settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
_tokens: set[str] = set()
class LoginRequest(BaseModel):
password: str
@@ -23,26 +22,41 @@ def hash_password(pw: str) -> str:
ADMIN_HASH = hash_password(settings.admin_password)
def _token_for(password: str) -> str:
"""The admin token for a password.
Derived, never stored. It used to live in a module-level set, which only works
while there is exactly one process: uvicorn loads this module once per worker,
so logging in on one worker left every other worker rejecting the token and
the admin appeared to be logged out at random. A value that can be recomputed
cannot drift between workers.
"""
return hashlib.sha256(f"{password}:token".encode()).hexdigest()
def _is_valid(token: str) -> bool:
# Constant-time compare against the one token this deployment can issue.
return hmac.compare_digest(token, _token_for(settings.admin_password))
async def require_auth(authorization: str = Header(None)) -> bool:
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
token = authorization.replace("Bearer ", "")
if token not in _tokens:
if not _is_valid(token):
raise HTTPException(status_code=401, detail="Invalid token")
return True
@router.post("/login")
async def login(body: LoginRequest):
if hash_password(body.password) != ADMIN_HASH:
if not hmac.compare_digest(hash_password(body.password), ADMIN_HASH):
raise HTTPException(status_code=401, detail="Invalid password")
token = hashlib.sha256(f"{body.password}:token".encode()).hexdigest()
_tokens.add(token)
return {"token": token}
return {"token": _token_for(body.password)}
@router.post("/verify")
async def verify(body: VerifyRequest):
if body.token not in _tokens:
if not _is_valid(body.token):
raise HTTPException(status_code=401, detail="Invalid token")
return {"valid": True}
+4
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from config import settings
from services.settings_store import save_settings
from routes.auth import require_auth
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
@@ -83,6 +84,9 @@ async def update_strategy(body: StrategyUpdate, _: bool = Depends(require_auth))
settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled
# Persisted for the same reason as the brand: in-memory only meant a restart
# silently restored the defaults.
save_settings()
return {"ok": True}
+34 -29
View File
@@ -2,11 +2,13 @@ import os
import shutil
from pathlib import Path
import psutil
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from config import settings
from routes.auth import require_auth
from services.settings_store import save_settings
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -45,12 +47,19 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)):
settings.brand_tagline = body.brand_tagline
if body.brand_logo_url is not None:
settings.brand_logo_url = body.brand_logo_url
# Without this the change lives only in memory and the next restart silently
# reverts the platform back to the config.py defaults.
save_settings()
return {"ok": True}
@router.get("/transcode-profiles")
async def get_transcode_profiles(_: bool = Depends(require_auth)):
return settings.transcode_profiles
# Wrapped in "profiles" to match what PUT accepts. Returning the bare mapping
# meant the settings page read `data.profiles`, got undefined, fell back to an
# empty object, and then every save sent `{"profiles": {}}` — which the PUT
# below rejects as "at least one profile", so saving settings could never work.
return {"profiles": settings.transcode_profiles}
@router.put("/transcode-profiles")
@@ -83,38 +92,33 @@ async def update_transcode_profiles(
settings.transcode_profiles = normalized
settings.qualities = next(iter(normalized.values()))
save_settings()
return {"ok": True, "profiles": normalized}
@router.get("/system-metrics")
async def get_system_metrics(_: bool = Depends(require_auth)):
cpu_count = os.cpu_count() or 1
# There is no load average on Windows: os.getloadavg does not exist there, and
# psutil's emulation reads as 0.0 until its background sampler warms up (and
# never means quite the same thing). Report real CPU utilisation instead, so
# the dashboard's "CPU 使用率" card is not permanently stuck at zero.
if hasattr(os, "getloadavg"):
load_1m, load_5m, load_15m = os.getloadavg()
memory_total = 0
memory_available = 0
meminfo = Path("/proc/meminfo")
if meminfo.exists():
values = {}
for line in meminfo.read_text(encoding="ascii").splitlines():
key, _, value = line.partition(":")
if value:
values[key] = int(value.strip().split()[0]) * 1024
memory_total = values.get("MemTotal", 0)
memory_available = values.get("MemAvailable", 0)
load_percent = round(min(100, load_1m / cpu_count * 100), 1)
else:
load_1m = load_5m = load_15m = None
# interval=None never blocks; it measures since the previous call anywhere
# in the process, which the supervisor's 10s metrics tick keeps fresh.
load_percent = round(psutil.cpu_percent(), 1)
memory = psutil.virtual_memory()
network = psutil.net_io_counters()
metrics_path = Path(settings.replay_media_dir).resolve()
disk_path = metrics_path if metrics_path.exists() else Path.cwd()
disk = shutil.disk_usage(disk_path)
cpu_count = os.cpu_count() or 1
network_in_bytes = 0
network_out_bytes = 0
netdev = Path("/proc/net/dev")
if netdev.exists():
for line in netdev.read_text(encoding="ascii").splitlines()[2:]:
_, _, counters = line.partition(":")
fields = counters.split()
if len(fields) >= 9:
network_in_bytes += int(fields[0])
network_out_bytes += int(fields[8])
return {
"cpu": {
@@ -122,12 +126,13 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"load_1m": load_1m,
"load_5m": load_5m,
"load_15m": load_15m,
"load_percent": round(min(100, load_1m / cpu_count * 100), 1),
"load_percent": load_percent,
"percent": load_percent,
},
"memory": {
"total": memory_total,
"available": memory_available,
"used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0,
"total": memory.total,
"available": memory.available,
"used_percent": round((1 - memory.available / memory.total) * 100, 1) if memory.total else 0,
},
"disk": {
"total": disk.total,
@@ -135,7 +140,7 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
},
"network": {
"bytes_recv": network_in_bytes,
"bytes_sent": network_out_bytes,
"bytes_recv": network.bytes_recv,
"bytes_sent": network.bytes_sent,
},
}
+126 -20
View File
@@ -1,18 +1,21 @@
import json
import secrets
import asyncio
import mimetypes
import time
import ntpath
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import Optional
from database import get_db
from database import get_db, session_factory
from routes.auth import require_auth
from services.stream_service import StreamService
from services.media_service import media_service
@@ -43,6 +46,7 @@ class StreamCreate(BaseModel):
cover_url: Optional[str] = None
scheduled_at: Optional[str] = None
replay_file: Optional[str] = None
replay_url: Optional[str] = None
chapters: list[dict] = Field(default_factory=list)
transcode_profile: str = "standard"
renditions: Optional[list[RenditionInput]] = None
@@ -56,6 +60,7 @@ class StreamUpdate(BaseModel):
cover_url: Optional[str] = None
scheduled_at: Optional[str] = None
replay_file: Optional[str] = None
replay_url: Optional[str] = None
chapters: Optional[list[dict]] = None
transcode_profile: Optional[str] = None
renditions: Optional[list[RenditionInput]] = None
@@ -108,11 +113,38 @@ def _media_path(filename: str | None) -> Path | None:
return candidate
def _valid_replay_url(url: str | None) -> str | None:
if not url:
return None
url = url.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(status_code=422, detail="回放 URL 必须是 http(s) 地址")
if " " in url or "\n" in url or "\r" in url:
raise HTTPException(status_code=422, detail="回放 URL 格式无效")
return url
def _utc_iso(moment: datetime) -> str:
"""Render a stored timestamp as an explicit UTC instant for JSON.
Rows are written with datetime.utcnow(), so the value is UTC but carries no
tzinfo. `isoformat()` therefore emits "2026-09-22T07:47:11" with no offset,
and JavaScript's Date() reads that as *local* time — in UTC+8 every heartbeat
looked eight hours old and the live board's "active viewer" filter discarded
every session, so the count was permanently zero. The trailing Z is what makes
the client parse it as UTC.
"""
if moment is None:
return None
return moment.replace(microsecond=0).isoformat() + "Z"
def _status_payload(stream) -> dict:
return {
"stream_id": stream.id,
"status": stream.status.value,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_local_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_url": stream.replay_url,
"chapters": json.loads(stream.chapters_json or "[]"),
}
@@ -169,6 +201,7 @@ def _serialize(s, include_private: bool = True):
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"replay_file": s.replay_file,
"replay_url": s.replay_url,
"chapters": json.loads(s.chapters_json or "[]"),
"transcode_profile": s.transcode_profile,
"renditions": json.loads(getattr(s, "renditions_json", None)) if getattr(s, "renditions_json", None) else None,
@@ -235,10 +268,33 @@ async def viewer_heartbeat(stream_id: int, body: ViewerHeartbeat,
raise HTTPException(status_code=422, detail="观众会话 ID 无效")
viewer = await db.get(ViewerSession, session_id)
if viewer and viewer.stream_id != stream_id:
raise HTTPException(status_code=409, detail="观众会话已用于其他节目")
# A session id lives in the viewer's browser, not per programme, so anyone
# who switches streams arrives still bound to the one they started on.
# Re-bind them rather than 409: "this session is watching something else"
# is not a conflict a viewer can act on, and leaving it stuck meant every
# later heartbeat and command poll for this viewer failed for good.
viewer.stream_id = stream_id
viewer.remote_adjusted = False
if not viewer:
viewer = ViewerSession(session_id=session_id, stream_id=stream_id)
db.add(viewer)
try:
await db.flush()
except IntegrityError:
# A first heartbeat can be sent twice in quick succession (React
# StrictMode runs mount effects twice in development), and both
# requests can miss the lookup above before either commits. The loser
# adopts the winner's row instead of returning a 500.
#
# Recovery runs on its own session: this one is now in a failed
# transaction, and a session that is still dirty at teardown makes
# get_db commit on exit and re-raise the very IntegrityError we just
# handled.
async with session_factory() as recovery:
viewer = await recovery.get(ViewerSession, session_id)
if not viewer:
raise HTTPException(status_code=409, detail="观众会话创建失败,请刷新页面")
return {"ok": True, "remote_adjusted": viewer.remote_adjusted}
viewer.node_id = body.node_id
viewer.node_name = body.node_name
viewer.quality = body.quality
@@ -267,7 +323,7 @@ async def list_viewer_sessions(stream_id: int, db: AsyncSession = Depends(get_db
"buffered_seconds": viewer.buffered_seconds,
"error": viewer.error,
"remote_adjusted": viewer.remote_adjusted,
"last_heartbeat": viewer.last_heartbeat.isoformat(),
"last_heartbeat": _utc_iso(viewer.last_heartbeat),
} for viewer in viewers]
@@ -285,8 +341,8 @@ async def control_audit(stream_id: int, db: AsyncSession = Depends(get_db),
"value": command.value,
"status": command.status,
"result": command.result,
"created_at": command.created_at.isoformat(),
"claimed_at": command.claimed_at.isoformat() if command.claimed_at else None,
"created_at": _utc_iso(command.created_at),
"claimed_at": _utc_iso(command.claimed_at),
} for command in commands]
@@ -317,6 +373,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
if not title:
raise HTTPException(status_code=422, detail="节目标题不能为空")
_media_path(body.replay_file)
_valid_replay_url(body.replay_url)
if body.transcode_profile not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在")
renditions = _normalize_renditions([item.model_dump() for item in body.renditions]) if body.renditions is not None else None
@@ -328,6 +385,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
cover_url=body.cover_url,
scheduled_at=body.scheduled_at,
replay_file=body.replay_file,
replay_url=body.replay_url,
chapters=body.chapters,
transcode_profile=body.transcode_profile,
renditions_json=json.dumps(renditions, ensure_ascii=False) if renditions is not None else None,
@@ -365,20 +423,34 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
existing = await svc.get_stream(stream_id)
if not existing:
raise HTTPException(status_code=404, detail="Stream not found")
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if ("replay_file" in updates or "chapters" in updates) and existing.status.value != "ended":
updates = body.model_dump(exclude_unset=True)
replay_fields = {"replay_file", "replay_url", "chapters"}
if replay_fields.intersection(updates) and existing.status.value != "ended":
raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节")
if "replay_file" in updates:
if updates.get("replay_file"):
_media_path(updates["replay_file"])
if "replay_url" in updates and updates["replay_url"] is not None:
updates["replay_url"] = _valid_replay_url(updates["replay_url"])
final_replay_file = updates.get("replay_file", existing.replay_file)
final_replay_url = updates.get("replay_url", existing.replay_url)
if final_replay_file and final_replay_url:
raise HTTPException(status_code=409, detail="本地回放文件和远端回放地址只能选择一种")
if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在")
if "renditions" in updates:
updates["renditions_json"] = json.dumps(_normalize_renditions(updates.pop("renditions")), ensure_ascii=False)
renditions = updates.pop("renditions")
updates["renditions_json"] = (
json.dumps(_normalize_renditions(renditions), ensure_ascii=False)
if renditions is not None else None
)
if "chapters" in updates:
if not updates.get("replay_file") and not existing.replay_file:
raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节")
chapters = _normalize_chapters(updates.pop("chapters"))
updates["chapters_json"] = json.dumps(chapters, ensure_ascii=False)
chapters = updates.pop("chapters")
if chapters and not (final_replay_file or final_replay_url):
raise HTTPException(status_code=409, detail="请先配置回放来源再编辑章节")
updates["chapters_json"] = json.dumps(_normalize_chapters(chapters or []), ensure_ascii=False)
if updates:
await svc.update_stream(stream_id, **updates)
return {"ok": True}
@@ -413,7 +485,8 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De
"qualities": urls["qualities"],
"description": stream.description,
"scheduled_at": str(stream.scheduled_at) if stream.scheduled_at else None,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_local_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_url": stream.replay_url,
"chapters": json.loads(stream.chapters_json or "[]"),
"remote_control_enabled": stream.remote_control_enabled,
}
@@ -451,10 +524,34 @@ async def serve_replay(stream_id: int, preview: str | None = None,
stream = await StreamService(db).get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="Program not found")
path = _media_path(preview if preview is not None else stream.replay_file)
filename = preview if preview is not None else stream.replay_file
path = _media_path(filename)
if not path:
raise HTTPException(status_code=404, detail="Replay not available")
return FileResponse(path)
# Authentication and the path-traversal check happen above, in Python; the
# bytes are handed to nginx. Returning FileResponse here would stream the
# whole file through this worker, which for a multi-gigabyte replay means the
# file is copied twice and one worker is occupied for the entire transfer.
#
# nginx answers `X-Accel-Redirect` on its own response by serving the named
# internal location instead of relaying the body, so the client still sees a
# single normal response.
root = Path(settings.replay_media_dir).resolve()
relative = path.relative_to(root).as_posix()
# Quote each segment: replay names can contain spaces or non-ASCII characters,
# and nginx interprets the header as a URI.
location = "/media-internal/" + "/".join(quote(part) for part in relative.split("/"))
response = Response(status_code=200)
response.headers["X-Accel-Redirect"] = location
# Let the client buffer ahead and seek; nginx serves the range requests.
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Type"] = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
# A direct hit on the backend (no nginx in front) must not look like a valid
# empty file, or a misconfigured deployment would silently play nothing.
response.headers["X-Replay-File"] = relative
return response
@router.post("/{stream_id}/remote-session")
@@ -520,8 +617,17 @@ async def send_remote_command(stream_id: int, body: RemoteCommand,
async def poll_remote_command(stream_id: int, session_id: str,
db: AsyncSession = Depends(get_db)):
viewer = await db.get(ViewerSession, session_id)
if not viewer or viewer.stream_id != stream_id:
if not viewer:
raise HTTPException(status_code=404, detail="观众会话不存在")
if viewer.stream_id != stream_id:
# A session id is stored per browser, not per programme, so a viewer who
# switches streams arrives with the id they registered on the old one.
# Remote control is meaningless if that 404s forever, so re-bind them
# instead — they are watching this stream now, which is the only fact the
# admin panel needs. Commands aimed at the old stream are not carried over.
viewer.stream_id = stream_id
viewer.remote_adjusted = False
await db.commit()
command = (await db.execute(select(ControlCommand).where(
ControlCommand.stream_id == stream_id,
ControlCommand.target_session_id == session_id,
@@ -534,7 +640,7 @@ async def poll_remote_command(stream_id: int, session_id: str,
viewer.remote_adjusted = command.action != "restore_auto"
await db.commit()
return {"id": command.id, "action": command.action, "value": command.value,
"created_at": command.created_at.isoformat()}
"created_at": _utc_iso(command.created_at)}
@router.post("/{stream_id}/go-live")
+117
View File
@@ -0,0 +1,117 @@
"""Pick the best available video encoder for the host.
Transcoding on the CPU is the fallback, not the default: a GPU encoder handles the
same ladder at a fraction of the CPU and leaves the cores free for the rest of the
server. Which encoder is usable cannot be decided from `ffmpeg -encoders` alone —
that lists everything the *build* supports, and a build with nvenc, amf and qsv
compiled in still fails at runtime on a machine with the wrong hardware (no NVIDIA
card, no Intel iGPU, a missing amfrt64.dll). So each candidate is probed by
actually encoding a frame.
The result is cached: probing costs a subprocess per candidate, and the answer
cannot change while the process is running.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
@dataclass(frozen=True)
class Encoder:
"""A usable ffmpeg video encoder plus the flags that suit it."""
name: str
# True when the encoder came from hardware rather than the CPU.
hardware: bool
# ffmpeg `-preset` value, already mapped to this encoder's own scale.
preset: str
# How a quality target is expressed for this encoder.
quality_flag: str # "-crf" or "-cq"
quality_value: int
# Value that means "encode every frame" for -sc_threshold where it applies.
supports_sc_threshold: bool
# Ranked by preference. nvenc is the only one that realistically serves this
# project today, but the others cost nothing to try and make the code portable to
# an Intel or AMD host.
# rate-control / preset translation between the software encoders and each family.
# The libraries share almost no vocabulary: libx264's -crf 23 is not h264_nvenc's
# -cq 23, and "ultrafast" is not a value nvenc accepts at all.
_SOFTWARE = {
"libx264": ("-crf", 23, "ultrafast"),
"libx265": ("-crf", 28, "ultrafast"),
}
_CANDIDATES = [
# (encoder, hardware, quality flag, quality value, preset, honours -sc_threshold)
("h264_nvenc", True, "-cq", 26, "p1", False),
("hevc_nvenc", True, "-cq", 28, "p1", False),
("h264_qsv", True, "-global_quality", 26, "veryfast", False),
("h264_amf", True, "-qp_i", 26, "speed", False),
("libx264", False, "-crf", 23, "ultrafast", True),
]
_cache: Encoder | None = None
_probe_log: list[str] = []
def _probe(encoder: str, quality_flag: str, quality_value: int, preset: str, sc_threshold: bool) -> bool:
"""Encode one synthetic frame; the only reliable test of "does this work here"."""
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=size=320x240:rate=25",
"-frames:v", "5",
"-c:v", encoder,
]
if preset:
cmd += ["-preset", preset]
cmd += [quality_flag, str(quality_value)]
if sc_threshold:
cmd += ["-sc_threshold", "0"]
cmd += ["-f", "null", "-"]
try:
result = subprocess.run(cmd, capture_output=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0
def detect_encoder() -> Encoder:
"""Return the highest-priority encoder that actually works on this host."""
global _cache
if _cache is not None:
return _cache
for name, hardware, quality_flag, quality_value, preset, sc_threshold in _CANDIDATES:
if _probe(name, quality_flag, quality_value, preset, sc_threshold):
_cache = Encoder(name, hardware, preset, quality_flag, quality_value, sc_threshold)
if hardware:
print(f"[transcoder] hardware encoder: {name}", flush=True)
else:
print(
f"[transcoder] no working hardware encoder; falling back to {name} "
f"(probed: {', '.join(_probe_log)})",
flush=True,
)
return _cache
_probe_log.append(name)
# libx264 ships in every ffmpeg build; reaching here means ffmpeg is missing.
_cache = Encoder("libx264", False, "ultrafast", "-crf", 23, True)
return _cache
def clear_encoder_cache() -> None:
"""Forget the probe result (used by tests)."""
global _cache
_cache = None
_probe_log.clear()
def probe_report() -> list[str]:
"""Encoders that were tried and rejected, in order."""
return list(_probe_log)
+10 -2
View File
@@ -22,8 +22,16 @@ class MediaMTXService:
if resp.status_code == 200:
data = resp.json()
for item in data.get("items", []):
if item.get("name") == stream_key:
return {"ready": item.get("ready", False), "source_ready": item.get("sourceReady", False)}
# Paths are named after the RTMP URL key, so a publisher on
# rtmp://host/live/<key> shows up as "live/<key>", never as
# the bare key. This MediaMTX exposes the reader list itself
# rather than a readersCount/sourceReady pair.
if item.get("name") in (stream_key, f"live/{stream_key}"):
return {
"ready": item.get("ready", False),
"source_ready": bool(item.get("source")),
"readers": len(item.get("readers") or []),
}
except Exception:
return None
return None
+158
View File
@@ -0,0 +1,158 @@
import asyncio
import os
import shutil
import subprocess
import httpx
from config import settings
def _log(message: str) -> None:
print(f"[mediamtx] {message}", flush=True)
def resolve_binary(configured: str) -> str | None:
"""Find the MediaMTX executable: the configured name on PATH, else the repo copy.
The repository ships both an ELF `mediamtx` and a Windows `mediamtx.exe`, so a
bare-name fallback has to prefer the platform's own extension first — picking
the other one fails at exec time with "not a valid Win32 application".
"""
found = shutil.which(configured)
if found:
return found
if os.path.dirname(configured):
return configured if os.path.isfile(configured) else None
repo_dir = os.path.dirname(os.path.abspath(settings.media_origin_config))
candidates = [f"{configured}.exe", configured] if os.name == "nt" else [configured, f"{configured}.exe"]
for candidate in candidates:
path = os.path.join(repo_dir, candidate)
if os.path.isfile(path):
return path
return None
class MediaMTXManager:
"""Keeps the MediaMTX origin alive.
The backend owns the media origin: it is the only thing that can tell whether
the origin is actually serving (its own /v3 API), and RTMP ingest is dead
without it while transcoders keep burning CPU against a missing source. When
an external supervisor already runs MediaMTX, the health probe below sees it
and this manager stays out of the way.
Only the process this manager started is ever stopped, so an externally
managed origin is never killed on shutdown.
"""
def __init__(self) -> None:
self._process: subprocess.Popen | None = None
self._failures = 0
self._binary_missing_logged = False
async def is_healthy(self) -> bool:
"""True when the origin API answers, regardless of who started it."""
try:
async with httpx.AsyncClient(timeout=2) as client:
response = await client.get(
f"http://{settings.media_origin_host}:{settings.media_origin_api_port}/v3/paths/list"
)
return response.status_code == 200
except Exception:
return False
def _started_process_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def start(self) -> None:
"""Launch MediaMTX unless one is already running. Never raises."""
if self._process is not None:
# A process we started exited; clear it so the next tick can retry.
if self._process.poll() is not None:
_log(f"process exited with code {self._process.returncode}")
self._process = None
else:
return
binary = resolve_binary(settings.media_origin_binary)
if binary is None:
# No MediaMTX anywhere is a deployment gap, not a crash: the API still
# serves, the admin UI can explain it, and RTMP ingest is simply down.
if not self._binary_missing_logged:
_log(f"'{settings.media_origin_binary}' not found on PATH or in mediamtx/; RTMP ingest unavailable")
self._binary_missing_logged = True
return
config_path = settings.media_origin_config
command = [binary]
if config_path and os.path.isfile(config_path):
command.append(config_path)
else:
_log(f"config not found at {config_path}; running with defaults")
config_path = ""
try:
self._process = subprocess.Popen(
command,
cwd=os.path.dirname(os.path.abspath(config_path)) if config_path else None,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
if not self._binary_missing_logged:
_log(f"failed to launch {binary}; RTMP ingest unavailable")
self._binary_missing_logged = True
self._process = None
except Exception as error: # pragma: no cover - defensive
_log(f"failed to start: {error!r}")
self._process = None
async def ensure_running(self) -> bool:
"""Start the origin if it is neither healthy nor already running.
Returns True when this call started it.
"""
if not settings.media_origin_autostart:
return False
if await self.is_healthy() or self._started_process_alive():
self._failures = 0
return False
# Back off after repeated failures so a broken binary cannot be relaunched
# every supervisor tick, which would spam processes and logs.
if self._failures:
self._failures -= 1
return False
self.start()
if self._process is None:
self._failures = 12
return False
self._failures = 0
_log(f"started (pid {self._process.pid})")
return True
async def wait_until_ready(self, timeout: float = 15.0) -> bool:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if await self.is_healthy():
return True
await asyncio.sleep(0.5)
return False
def stop(self) -> None:
"""Stop only the process this manager started."""
process, self._process = self._process, None
if process is None or process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=5)
except Exception:
try:
process.kill()
except Exception:
pass
mediamtx_mgr = MediaMTXManager()
+55
View File
@@ -0,0 +1,55 @@
"""Persist the settings an administrator edits in the UI.
config.py holds the defaults, but anything changed at runtime has to survive a
restart — otherwise editing the brand or the transcode ladder appears to work and
is silently reverted the next time the backend starts. Writes are atomic (temp
file + replace) so a crash mid-save cannot leave a half-written file that fails
to parse on boot.
"""
import json
from pathlib import Path
from config import settings
STORE_PATH = Path(__file__).resolve().parent.parent / "settings.json"
# Settings the UI may change, with the attribute they map to on Settings.
_MANAGED = (
"brand_name",
"brand_tagline",
"brand_logo_url",
"load_balance_strategy",
"multicast_enabled",
"transcode_profiles",
"qualities",
)
def load_settings() -> None:
"""Apply saved overrides on top of the config defaults. Never raises."""
if not STORE_PATH.exists():
return
try:
saved = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
# A corrupt file must not stop the server from booting; the defaults from
# config.py are still usable.
return
if not isinstance(saved, dict):
return
for key, value in saved.items():
# Only known keys, so a stale or hand-edited file cannot inject arbitrary
# attributes onto the settings object.
if key in _MANAGED and value is not None:
setattr(settings, key, value)
def save_settings() -> None:
payload = {key: getattr(settings, key) for key in _MANAGED}
temporary = STORE_PATH.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
temporary.replace(STORE_PATH)
+75 -5
View File
@@ -7,9 +7,24 @@ from services.multicast_detector import multicast_detector
from services.transcoder import transcode_mgr
from config import settings
import json
import re
from datetime import datetime
# MediaMTX rejects any HLS path that is not alphanumeric ("invalid path name: can
# contain only alphanumeric characters"), and the label is also used as a directory
# name, so every rendition needs a path-safe slug of its own.
def hls_path_slug(label: str) -> str:
"""Filesystem- and MediaMTX-safe rendition path for a display label."""
slug = re.sub(r"[^A-Za-z0-9]", "", label or "")
return slug or "rendition"
def with_hls_path(rendition: dict) -> dict:
"""Attach the `path` an admin-configured rendition is stored and served under."""
return {**rendition, "path": hls_path_slug(rendition.get("label", ""))}
class StreamService:
def __init__(self, db: AsyncSession):
self.db = db
@@ -25,12 +40,64 @@ class StreamService:
pass
return settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
@staticmethod
def source_label() -> str:
"""Display name of the stream-copy rendition, or '' when it is disabled.
A blank label would leak into the HLS path and produce a URL like
/hls/<key>//index.m3u8, so it degrades to "off" instead.
"""
return settings.source_rendition_label.strip() if settings.source_rendition_enabled else ""
@classmethod
def source_slug(cls) -> str:
"""Path segment for that rendition, safe for MediaMTX and the filesystem."""
if not cls.source_label():
return ""
return hls_path_slug(settings.source_rendition_slug)
def watch_renditions(self, stream: Stream) -> list:
"""Renditions a viewer may pick: the stream-copy rendition first, then the configured ones.
Each entry carries both the label shown to viewers and the `path` used in
HLS URLs — MediaMTX only accepts alphanumeric HLS paths, so a Chinese label
such as "原画" cannot double as a directory name.
The source entry carries no size: the input resolution is only known to
ffmpeg, so the player measures it from the media it ends up decoding.
"""
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
label, slug = self.source_label(), self.source_slug()
if not label or not slug or not renditions or renditions[0]["path"] == slug:
return renditions
return [{"label": label, "path": slug, "width": None, "height": None, "copy": True}] + renditions
def transcode_renditions(self, stream: Stream) -> list:
"""Renditions to hand to the transcoder, including the stream-copy source rendition.
Carries the same `path` values as watch_renditions so the files ffmpeg
writes are the files the player asks for.
The copy entry is the top rendition with its label replaced, so it carries
every key the transcoder reads. ffmpeg ignores those numbers for a stream
copy, but the supervisor in main.py compares TranscodeManager's recorded
signature against this list every tick — an entry missing a field would
both crash the start and make the signature drift.
"""
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
label, slug = self.source_label(), self.source_slug()
if not label or not slug or not renditions or renditions[0]["path"] == slug:
return renditions
return [{**renditions[0], "label": label, "path": slug, "copy": True}] + renditions
async def create_stream(self, title: str, description: str | None = None,
cover_url: str | None = None,
scheduled_at: str | None = None,
replay_file: str | None = None,
replay_url: str | None = None,
chapters: list[dict] | None = None,
transcode_profile: str = "standard",
renditions_json: str | None = None,
homepage_visible: bool = True,
remote_control_enabled: bool = False) -> Stream:
import uuid
@@ -45,8 +112,10 @@ class StreamService:
description=description,
cover_url=cover_url,
replay_file=replay_file,
replay_url=replay_url,
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
transcode_profile=transcode_profile,
renditions_json=renditions_json,
homepage_visible=homepage_visible,
remote_control_enabled=remote_control_enabled,
stream_key=uuid.uuid4().hex,
@@ -106,7 +175,7 @@ class StreamService:
started_at=datetime.utcnow(),
)
rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url, self.effective_renditions(stream))
transcode_mgr.start_transcode(stream_key, rtmp_url, self.transcode_renditions(stream))
async def set_stream_ended(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key)
@@ -123,7 +192,7 @@ class StreamService:
stream = await self.get_stream_by_key(stream_key)
if not stream:
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
qualities = self.effective_renditions(stream)
qualities = self.watch_renditions(stream)
best_node = lb.select_best_node(client_ip)
if not best_node:
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
@@ -140,12 +209,13 @@ class StreamService:
"qualities": [],
}
for quality in qualities:
label = quality["label"]
# The URL segment is the rendition's path slug, not its label:
# labels are display text and may be Chinese.
line_info["qualities"].append({
"label": label,
"label": quality["label"],
"width": quality["width"],
"height": quality["height"],
"url": f"{base_hls}/{label}/index.m3u8",
"url": f"{base_hls}/{quality['path']}/index.m3u8",
})
lines.append(line_info)
+181 -28
View File
@@ -1,19 +1,139 @@
import subprocess
import os
from dataclasses import dataclass
from config import settings
from services.hardware import detect_encoder
def _rendition_slug(quality: dict) -> str:
"""Path slug for a rendition, falling back to its label."""
return quality.get("path") or quality["label"]
@dataclass
class _Rendition:
"""One encoder process, remembered per rendition rather than per stream."""
slug: str
process: subprocess.Popen
class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {}
# Keyed by stream, then by rendition slug. Per-rendition tracking is what lets
# a single dead encoder be noticed: a stream-level list made a dead 原画
# invisible as long as any sibling encoder was still alive.
_renditions: dict[str, dict[str, _Rendition]] = {}
# Signature of the last successful start, so a supervisor can tell whether a
# running set of processes still matches the stream's current configuration.
_signatures: dict[str, str] = {}
# Number of renditions that failed to launch on the last attempt.
_failed_starts: dict[str, int] = {}
@staticmethod
def _signature(qualities: list[dict]) -> str:
# The hardware encoder is part of the signature: if detection ever changes
# (a driver appears, the GPU is removed), running processes are stale.
encoder = detect_encoder().name
return "|".join(
f"{q.get('label')}:{q.get('width')}x{q.get('height')}:{q.get('bitrate')}:"
f"{'copy' if q.get('copy') else encoder}"
for q in qualities
)
def is_running(self, stream_key: str) -> bool:
"""True when every rendition this stream should have is still alive."""
return not self.dead_renditions(stream_key)
def dead_renditions(self, stream_key: str) -> list[str]:
"""Slugs of renditions that have exited. Empty when all are healthy.
Missing sessions count as dead, so a stream that was never started
reports every rendition as needing one.
"""
renditions = self._renditions.get(stream_key)
if not renditions:
return ["*"]
return [slug for slug, r in renditions.items() if r.process.poll() is not None]
def needs_restart(self, stream_key: str, qualities: list[dict]) -> bool:
if not qualities:
return False
if self._signatures.get(stream_key) != self._signature(qualities):
return True
return bool(self.dead_renditions(stream_key))
def _clear_stale_output(self, hls_dir: str) -> None:
"""Drop the previous run's playlist and segments.
ffmpeg writes a live playlist with no #EXT-X-ENDLIST, so a playlist left
behind by an encoder that died looks to hls.js exactly like a stream that
simply stopped: it keeps reloading a file that will never change and
buffers forever. Removing it makes the player see a missing manifest
instead, which it retries until the supervisor has the encoder back up.
"""
if not os.path.isdir(hls_dir):
return
for name in os.listdir(hls_dir):
if name.endswith(".ts") or name.endswith(".m3u8") or name.endswith(".m3u8.tmp"):
try:
os.remove(os.path.join(hls_dir, name))
except OSError:
# A player holding the file open (Windows) is fine: it will be
# overwritten by the restarting encoder.
pass
def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]):
if stream_key in self._processes:
return
"""Start whichever renditions are not already alive.
procs = []
Restarting only the dead ones keeps the survivors streaming: tearing down
every encoder because one of them exited would black out the whole stream,
and a viewer on a healthy rendition has no reason to be interrupted.
"""
signature = self._signature(qualities)
if self._signatures.get(stream_key) != signature:
# The configuration changed, so nothing on disk is known-good.
self.stop_transcode(stream_key)
else:
self._reap_dead(stream_key)
live = self._renditions.setdefault(stream_key, {})
started = 0
segment_seconds = max(1, settings.hls_segment_seconds)
for quality in qualities:
slug = _rendition_slug(quality)
if slug in live:
continue # still running; leave it alone
cmd = self._build_command(stream_key, quality, input_url, segment_seconds)
try:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
continue
live[slug] = _Rendition(slug=slug, process=proc)
started += 1
self._signatures[stream_key] = signature
self._failed_starts[stream_key] = max(0, len(qualities) - len(live))
return started
def _reap_dead(self, stream_key: str) -> None:
"""Forget exited renditions so the next start can replace them."""
live = self._renditions.get(stream_key)
if not live:
return
for slug, rendition in list(live.items()):
if rendition.process.poll() is not None:
del live[slug]
def _build_command(self, stream_key: str, quality: dict, input_url: str, segment_seconds: int) -> list[str]:
label = quality["label"]
# The stream-copy ("原画") rendition carries the top rendition's numbers
# only so the signature stays complete; ffmpeg copies the input rather
# than encoding to them. See StreamService.transcode_renditions.
is_copy = bool(quality.get("copy"))
# Files land under the rendition's path slug, never its label: MediaMTX
# refuses non-alphanumeric HLS paths and the label may be Chinese.
path_slug = _rendition_slug(quality)
width = quality["width"]
height = quality["height"]
fps = quality.get("fps", 30)
@@ -24,40 +144,73 @@ class TranscodeManager:
audio_bitrate = quality.get("audio_bitrate", "128k")
keyframe_seconds = quality.get("keyframe_seconds", segment_seconds)
keyframe_frames = keyframe_seconds * fps
hls_dir = f"/tmp/hls/{stream_key}/{label}"
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug)
os.makedirs(hls_dir, exist_ok=True)
cmd = [
"ffmpeg", "-y",
"-i", input_url,
"-c:v", codec, "-preset", preset,
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
self._clear_stale_output(hls_dir)
hls_output = [
"-f", "hls",
"-hls_time", str(segment_seconds),
"-hls_list_size", "6",
"-hls_flags", "delete_segments+independent_segments+program_date_time",
"-hls_segment_filename", os.path.join(hls_dir, "segment_%03d.ts"),
os.path.join(hls_dir, "index.m3u8"),
]
# NOTE: ffmpeg's -reconnect* flags are HTTP/HTTPS-only protocol
# options and make RTMP inputs fail with "Option not found".
# RTMP drop recovery is handled by the supervisor in main.py,
# which restarts exited encoders on its next tick.
hls_input = ["-rw_timeout", "10000000", "-i", input_url]
if is_copy:
# Source ("原画") rendition: keep the publisher's video as-is and only
# re-wrap it, so the output keeps the exact incoming resolution,
# frame rate and bitrate. Video copy costs no CPU; audio is re-encoded
# because MPEG-TS cannot carry every codec a publisher might send, and
# one AAC track is a rounding error next to the video encoders.
return (
["ffmpeg", "-y"] + hls_input
+ [
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-copyts", "-start_at_zero",
"-map", "0:v:0", "-map", "0:a:0?",
]
+ hls_output
)
# Encode on the GPU when the host has one. The configured codec/preset
# describe the *intended* quality, but the values themselves are
# encoder-specific: libx264's -crf and "ultrafast" mean nothing to
# h264_nvenc, which wants -cq and p1..p7. So the target is re-expressed
# in the chosen encoder's own terms rather than passed through.
encoder = detect_encoder()
encode_args = ["-c:v", encoder.name]
if encoder.name == codec:
# The configured encoder is the one we picked: honour its settings.
encode_args += ["-preset", preset, encoder.quality_flag, str(crf)]
else:
encode_args += ["-preset", encoder.preset, encoder.quality_flag, str(encoder.quality_value)]
if encoder.supports_sc_threshold:
# Only the software encoders take this; nvenc rejects it.
encode_args += ["-sc_threshold", "0"]
return (
["ffmpeg", "-y"] + hls_input
+ encode_args
+ [
"-r", str(fps), "-b:v", bitrate,
"-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
"-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", audio_bitrate,
"-f", "hls",
"-hls_time", str(segment_seconds),
"-hls_list_size", "6",
"-hls_flags", "delete_segments+independent_segments+program_date_time",
"-hls_segment_filename", f"{hls_dir}/segment_%03d.ts",
f"{hls_dir}/index.m3u8",
]
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+ hls_output
)
procs.append(proc)
except FileNotFoundError:
pass
self._processes[stream_key] = procs
def stop_transcode(self, stream_key: str):
procs = self._processes.pop(stream_key, [])
for proc in procs:
live = self._renditions.pop(stream_key, {})
for rendition in live.values():
proc = rendition.process
try:
proc.terminate()
proc.wait(timeout=5)
@@ -68,7 +221,7 @@ class TranscodeManager:
pass
def stop_all(self):
for key in list(self._processes.keys()):
for key in list(self._renditions.keys()):
self.stop_transcode(key)
+16 -2
View File
@@ -49,7 +49,7 @@ class ReplayWorkflowTest(unittest.TestCase):
_normalize_chapters(chapters)
def test_status_payload_exposes_pending_live_and_ended_updates(self):
stream = SimpleNamespace(id=7, replay_file=None, chapters_json="[]")
stream = SimpleNamespace(id=7, replay_file=None, replay_url=None, chapters_json="[]")
for status in ("pending", "live", "ended"):
stream.status = SimpleNamespace(value=status)
self.assertEqual(_status_payload(stream)["status"], status)
@@ -57,5 +57,19 @@ class ReplayWorkflowTest(unittest.TestCase):
stream.replay_file = "session.mp4"
stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}])
payload = _status_payload(stream)
self.assertEqual(payload["replay_url"], "/api/streams/7/replay")
self.assertEqual(payload["replay_local_url"], "/api/streams/7/replay")
self.assertIsNone(payload["replay_url"])
self.assertEqual(payload["chapters"], [{"title": "Intro", "time": 0}])
stream.replay_file = None
stream.replay_url = "https://example.com/vod/master.m3u8"
payload = _status_payload(stream)
self.assertIsNone(payload["replay_local_url"])
self.assertEqual(payload["replay_url"], "https://example.com/vod/master.m3u8")
def test_replay_url_rejects_non_http(self):
from routes.streams import _valid_replay_url
for unsafe in ("ftp://example.com/a.mp4", "file:///etc/passwd", "javascript:alert(1)"):
with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException):
_valid_replay_url(unsafe)
self.assertEqual(_valid_replay_url(" https://cdn.example.com/a.mp4 "), "https://cdn.example.com/a.mp4")
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
+84
View File
@@ -0,0 +1,84 @@
param(
[string]$OriginUrl = "http://127.0.0.1:8080",
[string]$NodeId = "",
[string]$Region = "default",
[int]$HlsPort = 8889,
[int]$ApiPort = 9997,
[int]$Weight = 100,
[string]$DataDir = "",
[string]$ExePath = "",
[switch]$Build,
[switch]$NoWait
)
$ErrorActionPreference = "Stop"
$Base = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $NodeId) { $NodeId = $env:COMPUTERNAME }
if (-not $NodeId) { $NodeId = "windows-edge" }
if (-not $DataDir) { $DataDir = Join-Path $env:ProgramData ("StreamCore\edge\" + $NodeId) }
$LogDir = Join-Path $DataDir "logs"
New-Item -ItemType Directory -Force -Path $DataDir, $LogDir | Out-Null
function Assert-Command($Name, $Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "$Name not found. $Hint"
}
}
if ($Build) {
Assert-Command "go" "Install Go 1.19+ and make go.exe available in PATH."
Write-Host "[StreamCore Edge] Building Windows edge agent..."
& (Join-Path $Base "build.ps1")
}
$exeCandidates = @()
if ($ExePath) { $exeCandidates += $ExePath }
$exeCandidates += (Join-Path $Base "dist\streamcore-edge-windows-amd64.exe")
$exeCandidates += (Join-Path $Base "streamcore-edge.exe")
$ResolvedExe = $null
foreach ($candidate in $exeCandidates) {
if ($candidate -and (Test-Path $candidate)) {
$ResolvedExe = (Resolve-Path $candidate).Path
break
}
}
if (-not $ResolvedExe) {
throw "Windows edge agent executable not found. Run: powershell -ExecutionPolicy Bypass -File .\edge-agent\start-edge.ps1 -Build"
}
$env:STREAMCORE_ORIGIN_URL = $OriginUrl
$env:STREAMCORE_NODE_ID = $NodeId
$env:STREAMCORE_REGION = $Region
$env:STREAMCORE_HLS_PORT = "$HlsPort"
$env:STREAMCORE_API_PORT = "$ApiPort"
$env:STREAMCORE_WEIGHT = "$Weight"
$env:STREAMCORE_DATA_DIR = $DataDir
Write-Host "[StreamCore Edge] Starting Windows edge node..."
Write-Host " Node ID: $NodeId"
Write-Host " Region: $Region"
Write-Host " Origin: $OriginUrl"
Write-Host " HLS: http://127.0.0.1:$HlsPort/"
Write-Host " API: http://127.0.0.1:$ApiPort/"
Write-Host " Data dir: $DataDir"
Write-Host " Logs: $LogDir"
$process = Start-Process -FilePath $ResolvedExe -WorkingDirectory $Base -PassThru -RedirectStandardOutput (Join-Path $LogDir "edge-agent.out.log") -RedirectStandardError (Join-Path $LogDir "edge-agent.err.log")
Write-Host "[StreamCore Edge] Process started with PID $($process.Id)."
if ($NoWait) { return }
Write-Host "Press Ctrl+C to stop the edge node."
try {
while (-not $process.HasExited) { Start-Sleep -Seconds 2 }
exit $process.ExitCode
} finally {
if (-not $process.HasExited) {
Write-Host "[StreamCore Edge] Stopping edge node..."
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+2 -4
View File
@@ -1,12 +1,10 @@
<!DOCTYPE html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StreamCore</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+28 -6
View File
@@ -4,23 +4,45 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
"dev": "vite --host 0.0.0.0 --port 5175",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 5175"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"antd": "^5.21.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hls.js": "^1.5.14",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.5.6",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2",
"vite": "^5.4.7"
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+49 -38
View File
@@ -1,24 +1,23 @@
import { Component, lazy, Suspense, useState, useEffect, type ErrorInfo, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { Button, ConfigProvider, Result } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import theme from './theme/anthropic'
import './pageTransitions.css'
import { BrandProvider, useBrand } from './brand/BrandContext'
import { verifyToken } from './api/auth'
import { getToken } from './api/client'
import { Component, lazy, Suspense, useEffect, useState, type ErrorInfo, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'sonner'
import { Button } from '@/components/ui/button'
import { BrandProvider, useBrand } from '@/brand/BrandContext'
import { verifyToken } from '@/api/auth'
import { getToken } from '@/api/client'
const HomePage = lazy(() => import('./viewer/pages/HomePage'))
const WatchPage = lazy(() => import('./viewer/pages/WatchPage'))
const AdminLayout = lazy(() => import('./admin/AdminLayout'))
const LoginPage = lazy(() => import('./admin/LoginPage'))
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
const LiveBoard = lazy(() => import('./admin/pages/LiveBoard'))
const StreamList = lazy(() => import('./admin/pages/StreamList'))
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
const NodeManager = lazy(() => import('./admin/pages/NodeManager'))
const SettingsPage = lazy(() => import('./admin/pages/Settings'))
const HomePage = lazy(() => import('@/viewer/pages/HomePage'))
const WatchPage = lazy(() => import('@/viewer/pages/WatchPage'))
const AdminLayout = lazy(() => import('@/admin/AdminLayout'))
const LoginPage = lazy(() => import('@/admin/LoginPage'))
const Dashboard = lazy(() => import('@/admin/pages/Dashboard'))
const LiveBoard = lazy(() => import('@/admin/pages/LiveBoard'))
const StreamList = lazy(() => import('@/admin/pages/StreamList'))
const StreamForm = lazy(() => import('@/admin/pages/StreamForm'))
const ReplayEditor = lazy(() => import('@/admin/pages/ReplayEditor'))
const NodeManager = lazy(() => import('@/admin/pages/NodeManager'))
const SettingsPage = lazy(() => import('@/admin/pages/Settings'))
const SwaggerPage = lazy(() => import('@/admin/pages/SwaggerPage'))
class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false }
@@ -34,24 +33,26 @@ class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boo
render() {
if (this.state.failed) {
return (
<Result
status="error"
title="页面加载失败"
subTitle="页面遇到异常,请重新加载后再试。"
extra={<Button type="primary" onClick={() => window.location.reload()}>重新加载</Button>}
/>
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-semibold">页面加载失败</h1>
<p className="text-muted-foreground">页面遇到异常,请重新加载后再试。</p>
<Button onClick={() => window.location.reload()}>重新加载</Button>
</div>
)
}
return this.props.children
}
}
function AdminGuard({ children }: { children: React.ReactNode }) {
function AdminGuard({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState<boolean | null>(null)
useEffect(() => {
const token = getToken()
if (!token) { setAuthed(false); return }
if (!token) {
setAuthed(false)
return
}
verifyToken().then(setAuthed)
}, [])
@@ -60,22 +61,32 @@ function AdminGuard({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
function AppRoutes() {
const location = useLocation()
const routeTransitionKey = location.pathname.startsWith('/admin') ? '/admin' : location.pathname
function RouteShell() {
const { brand } = useBrand()
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
useEffect(() => {
document.title = brand.brand_name
}, [brand.brand_name])
return (
<PageErrorBoundary>
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中"><span /></div>}>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
}
>
<Routes>
<Route path="/watch/:id" element={<WatchPage />} />
<Route path="/admin/login" element={<LoginPage />} />
<Route path="/admin" element={
<Route
path="/admin"
element={
<AdminGuard>
<AdminLayout />
</AdminGuard>
}>
}
>
<Route index element={<Dashboard />} />
<Route path="board" element={<LiveBoard />} />
<Route path="streams" element={<StreamList />} />
@@ -83,6 +94,7 @@ function AppRoutes() {
<Route path="streams/:id/edit" element={<ReplayEditor />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="swagger" element={<SwaggerPage />} />
</Route>
<Route path="/" element={<HomePage />} />
</Routes>
@@ -93,12 +105,11 @@ function AppRoutes() {
export default function App() {
return (
<ConfigProvider theme={theme} locale={zhCN}>
<BrandProvider>
<BrowserRouter>
<AppRoutes />
<RouteShell />
<Toaster position="top-right" richColors closeButton />
</BrowserRouter>
</BrandProvider>
</ConfigProvider>
)
}
+102 -71
View File
@@ -1,33 +1,28 @@
import { Suspense, useEffect, useState } from 'react'
import { Outlet, useNavigate, useLocation , useOutlet } from 'react-router-dom'
import { Layout, Menu, Button, Typography } from 'antd'
import { useState } from 'react'
import { Outlet, useNavigate, useLocation, NavLink } from 'react-router-dom'
import {
DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
CloudServerOutlined, SettingOutlined, LogoutOutlined,
} from '@ant-design/icons'
import { clearToken } from '../api/client'
import { useBrand } from '../brand/BrandContext'
LayoutDashboard,
MonitorPlay,
Video,
Plus,
Server,
Settings,
LogOut,
FileCode2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { clearToken } from '@/api/client'
import { useBrand } from '@/brand/BrandContext'
import { cn } from '@/lib/utils'
const { Sider, Content, Header } = Layout
const { Text } = Typography
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/board', icon: <MonitorOutlined />, label: '直播看板' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
]
const adminPageImports = [
() => import('./pages/Dashboard'),
() => import('./pages/LiveBoard'),
() => import('./pages/StreamList'),
() => import('./pages/StreamForm'),
() => import('./pages/ReplayEditor'),
() => import('./pages/NodeManager'),
() => import('./pages/Settings'),
const navItems = [
{ to: '/admin', label: '概览', icon: LayoutDashboard, end: true },
{ to: '/admin/board', label: '直播看板', icon: MonitorPlay },
{ to: '/admin/streams', label: '节目列表', icon: Video },
{ to: '/admin/streams/new', label: '创建节目', icon: Plus },
{ to: '/admin/nodes', label: '边缘节点', icon: Server },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
{ to: '/admin/swagger', label: 'API 文档', icon: FileCode2 },
]
export default function AdminLayout() {
@@ -35,55 +30,91 @@ export default function AdminLayout() {
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const outlet = useOutlet()
const selectedKey = menuItems.find(
item => location.pathname === item.key || (location.pathname === '/' && item.key === '/admin')
)?.key || '/admin'
const handleLogout = () => {
clearToken()
navigate('/admin/login')
}
const handleLogout = () => { clearToken(); navigate('/admin/login') }
const currentLabel =
navItems
.filter((item) => location.pathname === item.to || (!item.end && location.pathname.startsWith(item.to + '/')))
.sort((a, b) => b.to.length - a.to.length)[0]?.label || '管理控制台'
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible collapsed={collapsed} onCollapse={setCollapsed}
width={220}
style={{
borderRight: '1px solid #EBE5DB',
background: '#fff',
}}
<div className="flex min-h-screen bg-background">
{/* Sidebar */}
<aside
className={cn(
'flex shrink-0 flex-col border-r border-border bg-card transition-all duration-200',
collapsed ? 'w-16' : 'w-56',
)}
>
<div style={{
height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid #EBE5DB',
}}>
<Text strong style={{
fontSize: collapsed ? 14 : 17, letterSpacing: '-0.02em',
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
color: '#C2693A',
}}>
{collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name}
</Text>
<div className="flex h-14 items-center gap-2 border-b border-border px-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">
{brand.brand_name.slice(0, 1)}
</span>
{!collapsed && (
<span className="truncate text-sm font-semibold text-foreground">{brand.brand_name}</span>
)}
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
{navItems.map((item) => {
const Icon = item.icon
const active = item.end
? location.pathname === item.to
: location.pathname === item.to || location.pathname.startsWith(item.to + '/')
return (
<NavLink
key={item.to}
to={item.to}
title={collapsed ? item.label : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="border-t border-border p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3"
onClick={() => setCollapsed((v) => !v)}
>
<Settings className="h-4 w-4 shrink-0" />
{!collapsed && <span>{collapsed ? '展开' : '收起侧栏'}</span>}
</Button>
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-card px-6">
<nav className="flex items-center gap-1.5 text-sm">
<span className="text-muted-foreground">管理控制台</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{currentLabel}</span>
</nav>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="h-4 w-4" />
退出登录
</Button>
</header>
<main className="min-w-0 flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
<Menu mode="inline" selectedKeys={[selectedKey]} items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ border: 'none', marginTop: 8 }} />
</Sider>
<Layout>
<Header style={{
background: '#fff', borderBottom: '1px solid #EBE5DB',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0 48px',
}}>
<Text style={{ color: '#949088', fontSize: 13 }}>{brand.brand_tagline}</Text>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出登录</Button>
</Header>
<Content style={{ padding: 40, overflow: 'auto' }}>
<div className="admin-page-transition" key={location.pathname}>
{outlet}
</div>
</Content>
</Layout>
</Layout>
)
}
+54 -48
View File
@@ -1,70 +1,76 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Typography, message } from 'antd'
import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import { login } from '../api/auth'
import { useBrand } from '../brand/BrandContext'
const { Title, Text } = Typography
import { Lock } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { login } from '@/api/auth'
import { useBrand } from '@/brand/BrandContext'
export default function LoginPage() {
const { brand } = useBrand()
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleSubmit = async (values: { password: string }) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password) {
toast.error('请输入管理员密码')
return
}
setLoading(true)
try { await login(values.password); message.success('登录成功'); navigate('/admin') }
catch { message.error('密码错误') }
finally { setLoading(false) }
try {
await login(password)
toast.success('登录成功')
navigate('/admin')
} catch {
toast.error('密码错误')
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: '100vh',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#F9F7F4',
}}>
<div style={{ width: '100%', maxWidth: 420, padding: 24 }}>
<div style={{
background: '#fff',
borderRadius: 14,
border: '1px solid #EBE5DB',
padding: 56,
}}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="mb-8 text-center">
{brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name} style={{ height: 36, marginBottom: 20 }} />
<img
src={brand.brand_logo_url}
alt={brand.brand_name}
className="mx-auto mb-5 h-9 object-contain"
/>
)}
<Title style={{ margin: '0 0 8px', fontSize: 28, fontWeight: 600, letterSpacing: '-0.02em' }}>
{brand.brand_name}
</Title>
<Text style={{ color: '#5E5A52', fontSize: 15 }}>
{brand.brand_tagline || '管理控制台'}
</Text>
<h1 className="text-2xl font-semibold tracking-tight">{brand.brand_name}</h1>
<p className="mt-1 text-sm text-muted-foreground">{brand.brand_tagline || '管理控制台'}</p>
</div>
<Form onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item name="password" rules={[{ required: true, message: '请输入管理员密码' }]}
style={{ marginBottom: 28 }}>
<Input.Password
prefix={<LockOutlined style={{ color: '#949088' }} />}
placeholder="管理员密码"
style={{ height: 48, borderRadius: 8 }}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="password">管理员密码</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入管理员密码"
className="h-11 pl-9"
autoFocus
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block
style={{ height: 48, fontSize: 16, fontWeight: 600, borderRadius: 8 }}>
登录管理控制台
</div>
</div>
<Button type="submit" size="lg" className="w-full" disabled={loading}>
{loading ? '登录中…' : '登录管理控制台'}
</Button>
</Form.Item>
</Form>
</form>
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button type="text" icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{ color: '#949088', fontSize: 13 }}>
<div className="mt-6 text-center">
<Button variant="ghost" size="sm" onClick={() => navigate('/')}>
返回首页
</Button>
</div>
+83 -76
View File
@@ -1,92 +1,99 @@
import { useState, useEffect } from 'react'
import { Row, Col, Statistic, Table, Tag, Typography } from 'antd'
import { SignalFilled, ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography
import { Radio, Clock, PlayCircle } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
import { StatusBadge } from '@/components/status-badge'
import { fetchStreams, type StreamInfo } from '@/api/streams'
import { formatDateTime } from '@/lib/status'
export default function Dashboard() {
const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
async function loadData() {
try { setStreams(await fetchStreams(true)) } catch { /* */ }
setLoading(false)
useEffect(() => {
let active = true
const loadData = async () => {
try {
const data = await fetchStreams(true)
if (active) setStreams(data)
} catch {
/* ignore */
}
const liveCount = streams.filter(s => s.status === 'live').length
const pendingCount = streams.filter(s => s.status === 'pending').length
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '待开播' },
live: { color: 'green', label: '直播中' },
ended: { color: 'default', label: '已结束' },
cancelled: { color: 'red', label: '已取消' },
if (active) setLoading(false)
}
void loadData()
const t = setInterval(loadData, 10000)
return () => {
active = false
clearInterval(t)
}
}, [])
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={(statusMap[s] || {}).color}>{(statusMap[s] || { label: s }).label}</Tag> },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 170 },
]
const liveCount = streams.filter((s) => s.status === 'live').length
const pendingCount = streams.filter((s) => s.status === 'pending').length
return (
<div>
<div style={{ marginBottom: 36 }}>
<Title level={1} style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 32, letterSpacing: '-0.03em' }}>
{brand.brand_name}
</Title>
<Text style={{ color: '#5E5A52', fontSize: 15 }}>管理控制台</Text>
<div className="space-y-6">
<PageHeader title="概览" kicker="Dashboard" description="节目状态与近期动态,每 10 秒自动刷新" />
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="播出中" value={loading ? '—' : liveCount} icon={Radio} tone="success" />
<StatCard label="待开播" value={loading ? '—' : pendingCount} icon={Clock} />
<StatCard label="节目总数" value={loading ? '—' : streams.length} icon={PlayCircle} />
</div>
<Row gutter={[24, 24]} style={{ marginBottom: 36 }}>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
borderTop: '3px solid #5B8C5A',
}}>
<Statistic title="播出中" value={liveCount} loading={loading}
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
borderTop: '3px solid #6B8CA8',
}}>
<Statistic title="待开播" value={pendingCount} loading={loading}
prefix={<ClockCircleOutlined style={{ color: '#6B8CA8', fontSize: 20 }} />} />
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
borderTop: '3px solid #C2693A',
}}>
<Statistic title="节目总数" value={streams.length} loading={loading}
prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} />
</div>
</Col>
</Row>
<div style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB',
padding: '28px 32px',
}}>
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
<Table dataSource={streams.slice(0, 10)} columns={columns}
rowKey="id" loading={loading} pagination={false} size="middle"
locale={{ emptyText: '暂无节目数据' }} />
</div>
<Card>
<CardHeader>
<CardTitle>最近节目</CardTitle>
<CardDescription>最新创建的 10 个节目</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>标题</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-44">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : streams.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center text-muted-foreground">
暂无节目数据
</TableCell>
</TableRow>
) : (
streams.slice(0, 10).map((s) => (
<TableRow key={s.id}>
<TableCell className="max-w-0 truncate font-medium">{s.title}</TableCell>
<TableCell>
<StatusBadge status={s.status} />
</TableCell>
<TableCell className="text-muted-foreground">{formatDateTime(s.created_at)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
+215 -96
View File
@@ -1,8 +1,26 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
import { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
import { getSystemMetrics, type SystemMetrics } from '../../api/system'
import { RefreshCw, Maximize2, Minimize2, Users, Signal, TriangleAlert, Cpu, MemoryStick, HardDrive, Download, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import api from '@/api/client'
import { getSystemMetrics, type SystemMetrics } from '@/api/system'
import {
createRemoteSession,
fetchStreams,
@@ -10,9 +28,10 @@ import {
sendRemoteCommand,
type StreamInfo,
type ViewerSessionInfo,
} from '../../api/streams'
const { Title, Text } = Typography
} from '@/api/streams'
import { formatBytes } from '@/lib/status'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
interface NodeMetric {
id: string
@@ -27,19 +46,13 @@ interface NodeMetric {
} | null
}
const formatBytes = (value = 0) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let amount = Math.max(0, value)
let index = 0
while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`
}
const QUALITIES = ['1080p', '720p', '480p', '360p']
export default function LiveBoard() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [streamId, setStreamId] = useState<number>()
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
const [selected, setSelected] = useState<React.Key[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [nodes, setNodes] = useState<NodeMetric[]>([])
@@ -51,7 +64,7 @@ export default function LiveBoard() {
try {
const programs = await fetchStreams(true)
setStreams(programs)
const nextId = streamId || programs.find(program => program.status === 'live')?.id
const nextId = streamId || programs.find((p) => p.status === 'live')?.id
if (nextId) {
setStreamId(nextId)
setSessions(await fetchViewerSessions(nextId))
@@ -59,15 +72,19 @@ export default function LiveBoard() {
setSessions([])
}
} catch {
message.error('看板数据加载失败')
toast.error('看板数据加载失败')
} finally {
try {
const [metrics, nodeResponse] = await Promise.all([getSystemMetrics(), api.get<NodeMetric[]>('/nodes')])
setSystemMetrics(metrics)
setNodes(nodeResponse.data)
setSystemMetrics(await getSystemMetrics())
} catch {
setSystemMetrics(null)
}
try {
const nodeResponse = await api.get<NodeMetric[]>('/nodes')
setNodes(nodeResponse.data)
} catch {
setNodes([])
}
setLoading(false)
}
}
@@ -79,101 +96,203 @@ export default function LiveBoard() {
}, [streamId])
async function controlViewers() {
if (!streamId || selected.length === 0) return
if (!streamId || selected.size === 0) return
try {
const session = await createRemoteSession(streamId)
const targets = selected.map(String)
const targets = Array.from(selected)
if (nodeId !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_node', targets, nodeId)
if (quality !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_quality', targets, quality)
if (nodeId === 'auto' && quality === 'auto') await sendRemoteCommand(streamId, session.token, 'restore_auto', targets)
message.success(`已向 ${selected.length} 个观众下发节点与清晰度策略`)
toast.success(`已向 ${selected.size} 个观众下发节点与清晰度策略`)
} catch {
message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
toast.error('控制命令下发失败,请确认节目已启用审计式播放控制')
}
}
const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000)
const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1)
const activeSessions = sessions.filter((s) => Date.now() - new Date(s.last_heartbeat).getTime() < 30000)
const buffering = activeSessions.filter(
(s) => s.playback_state === 'buffering' || Number(s.buffered_seconds || 0) < 1,
)
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<Title level={2} style={{ margin: 0 }}>直播看板</Title>
<Text type="secondary">实时检查节目、观众会话、线路与播放健康度</Text>
</div>
<Space wrap>
<Select
value={streamId}
placeholder="选择直播节目"
style={{ minWidth: 220 }}
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
onChange={value => { setStreamId(value); setSelected([]) }}
/>
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
</Space>
<div className={wallMode ? 'fixed inset-0 z-50 overflow-auto bg-background p-6' : 'space-y-6'}>
<PageHeader title="直播看板" kicker="Live Board" description="实时检查节目、观众会话、线路与播放健康度">
<Select value={streamId ? String(streamId) : undefined} onValueChange={(v) => { setStreamId(Number(v)); setSelected(new Set()) }}>
<SelectTrigger className="w-56">
<SelectValue placeholder="选择直播节目" />
</SelectTrigger>
<SelectContent>
{streams.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.title} · {p.status}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={() => setWallMode((v) => !v)}>
{wallMode ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{wallMode ? '退出大屏' : '大屏模式'}
</Button>
<Button variant="outline" onClick={load}>
<RefreshCw className="h-4 w-4" />
刷新
</Button>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="活跃观众" value={activeSessions.length} icon={Users} />
<StatCard label="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} icon={Signal} tone="success" />
<StatCard label="疑似卡顿" value={buffering.length} icon={TriangleAlert} tone={buffering.length ? 'warning' : 'default'} />
</div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}><Card><Statistic title="活跃观众" value={activeSessions.length} prefix={<TeamOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} prefix={<SignalFilled />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
</Row>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="CPU 使用率" value={`${systemMetrics?.cpu_percent.toFixed(1) ?? '—'}%`} icon={Cpu} />
<StatCard label="内存使用率" value={`${systemMetrics?.memory_percent.toFixed(1) ?? '—'}%`} icon={MemoryStick} />
<StatCard label="存储使用率" value={`${systemMetrics?.disk_percent.toFixed(1) ?? '—'}%`} icon={HardDrive} />
<StatCard label="下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} icon={Download} />
<StatCard label="上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} icon={Upload} />
</div>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.memory_used_bytes)} / {formatBytes(systemMetrics?.memory_total_bytes)}</Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.disk_used_bytes)} / {formatBytes(systemMetrics?.disk_total_bytes)}</Text></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} /></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} /></Card></Col>
</Row>
<Card title="节点实时指标">
<Row gutter={[12, 12]}>
{nodes.map(node => <Col xs={24} md={12} xl={8} key={node.id}><Card size="small" title={<Space>{node.name}<Tag color={node.online ? 'green' : 'default'}>{node.online ? '在线' : '离线'}</Tag></Space>}><Space direction="vertical"><Text>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</Text><Text>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</Text><Text>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>观众:{node.metrics?.viewer_count ?? 0}</Text></Space></Card></Col>)}
</Row>
<Card>
<CardHeader>
<CardTitle className="text-base">节点实时指标</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map((node) => (
<div key={node.id} className="rounded-md border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="font-medium">{node.name}</span>
<Badge variant={node.online ? 'default' : 'secondary'}>{node.online ? '在线' : '离线'}</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</div>
<div>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</div>
<div>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</div>
<div>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</div>
<div>观众:{node.metrics?.viewer_count ?? 0}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
<Card
title="审计式观众会话"
extra={
<Space wrap>
<Select value={nodeId} onChange={setNodeId} style={{ width: 180 }} options={[{ value: 'auto', label: '节点:保持自动' }, ...nodes.filter(node => node.online).map(node => ({ value: node.id, label: `节点:${node.name}` }))]} />
<Select value={quality} onChange={setQuality} style={{ width: 160 }} options={[
{ value: 'auto', label: '恢复自动' },
{ value: '1080p', label: '1080p' },
{ value: '720p', label: '720p' },
{ value: '480p', label: '480p' },
{ value: '360p', label: '360p' },
]} />
<Button type="primary" disabled={!selected.length} onClick={controlViewers}>批量切换</Button>
</Space>
}
>
<Table
rowKey="session_id"
loading={loading}
dataSource={sessions}
scroll={{ x: 850 }}
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
pagination={{ pageSize: 20 }}
columns={[
{ title: '会话', dataIndex: 'session_id', ellipsis: true, width: 180 },
{ title: '节点', dataIndex: 'node_name', render: value => value || '未知' },
{ title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
{ title: '状态', dataIndex: 'playback_state', render: value => <Tag color={value === 'playing' ? 'green' : value === 'buffering' ? 'orange' : 'default'}>{value}</Tag> },
{ title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
{ title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? <Tag color="gold">已调整</Tag> : '否' },
{ title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
{ title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
]}
{streamId && (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">审计式观众会话</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Select value={nodeId} onValueChange={setNodeId}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">节点:保持自动</SelectItem>
{nodes.filter((n) => n.online).map((n) => (
<SelectItem key={n.id} value={n.id}>
节点:{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={quality} onValueChange={setQuality}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">恢复自动</SelectItem>
{QUALITIES.map((q) => (
<SelectItem key={q} value={q}>
{q}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={selected.size === 0} onClick={controlViewers}>
批量切换
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={sessions.length > 0 && selected.size === sessions.length}
onChange={(e) => setSelected(e.target.checked ? new Set(sessions.map((s) => s.session_id)) : new Set())}
className="h-4 w-4 accent-primary"
/>
</TableHead>
<TableHead>会话</TableHead>
<TableHead>节点</TableHead>
<TableHead>清晰度</TableHead>
<TableHead>状态</TableHead>
<TableHead>缓冲</TableHead>
<TableHead>远程调整</TableHead>
<TableHead>最后心跳</TableHead>
<TableHead>错误</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
暂无观众会话
</TableCell>
</TableRow>
) : (
sessions.map((s) => (
<TableRow key={s.session_id}>
<TableCell>
<input
type="checkbox"
checked={selected.has(s.session_id)}
onChange={() => toggleSelect(s.session_id)}
className="h-4 w-4 accent-primary"
/>
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.session_id.slice(0, 12)}…</code>
</TableCell>
<TableCell>{s.node_name || '未知'}</TableCell>
<TableCell>{s.quality || '自动'}</TableCell>
<TableCell>
<Badge variant={s.playback_state === 'playing' ? 'default' : s.playback_state === 'buffering' ? 'secondary' : 'outline'}>
{s.playback_state}
</Badge>
</TableCell>
<TableCell>{(Number(s.buffered_seconds || 0)).toFixed(1)}s</TableCell>
<TableCell>{s.remote_adjusted ? <Badge className="bg-amber-600">已调整</Badge> : '否'}</TableCell>
<TableCell className="text-muted-foreground">{new Date(s.last_heartbeat).toLocaleTimeString()}</TableCell>
<TableCell className="max-w-0 truncate text-muted-foreground">{s.error || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</Space>
</div>
)
}
+230 -95
View File
@@ -1,129 +1,264 @@
import { useState, useEffect } from 'react'
import { Table, Input, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import api from '@/api/client'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
const { Title, Text } = Typography
interface NodeMetrics {
cpu_percent: number | null
memory_percent: number | null
bandwidth_mbps: number | null
active_connections: number | null
hls_readers: number | null
network_in_mbps: number | null
network_out_mbps: number | null
viewer_count: number | null
frame_drop_indicators: number | null
packet_loss_indicators: number | null
media_metrics_available: boolean
}
interface NodeInfo {
id: string
name: string
host: string
hls_port: number
api_port: number
weight: number
enabled: boolean
subnet: string
is_origin: boolean
online: boolean
metrics: NodeMetrics | null
}
interface StrategyInfo {
strategy: string
multicast_enabled: boolean
multicast_available: boolean
}
interface NodeMetrics { cpu_percent: number | null; memory_percent: number | null; bandwidth_mbps: number | null; active_connections: number | null; hls_readers: number | null; network_in_mbps: number | null; network_out_mbps: number | null; viewer_count: number | null; frame_drop_indicators: number | null; packet_loss_indicators: number | null; media_metrics_available: boolean }
interface NodeInfo { id: string; name: string; host: string; hls_port: number; api_port: number; weight: number; enabled: boolean; subnet: string; is_origin: boolean; online: boolean; metrics: NodeMetrics | null }
interface StrategyInfo { strategy: string; multicast_enabled: boolean; multicast_available: boolean }
const sL: Record<string, string> = { 'subnet': '子网优先', 'round-robin': '轮询', 'weighted': '权重', 'least-conn': '最少连接' }
const STRATEGY_LABEL: Record<string, string> = {
subnet: '子网优先',
'round-robin': '轮询',
weighted: '权重',
'least-conn': '最少连接',
}
export default function NodeManager() {
const [nodes, setNodes] = useState<NodeInfo[]>([])
const [strategy, setStrategy] = useState<StrategyInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
useEffect(() => {
void loadData()
const t = setInterval(loadData, 10000)
return () => clearInterval(t)
}, [])
async function loadData() {
setLoading(true)
try { const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')]); setNodes(nr.data); setStrategy(sr.data) }
catch { message.error('加载失败') }
try {
const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')])
setNodes(nr.data)
setStrategy(sr.data)
} catch {
toast.error('加载失败')
}
setLoading(false)
}
async function updateWeight(nid: string, w: number) {
try { await api.put(`/nodes/${nid}`, { weight: w }); message.success('已更新') } catch { message.error('更新失败') }
try {
await api.put(`/nodes/${nid}`, { weight: w })
toast.success('已更新')
} catch {
toast.error('更新失败')
}
}
async function toggleNode(nid: string, en: boolean) {
const node = nodes.find(n => n.id === nid); if (!node) return
try { await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }); setNodes(p => p.map(n => n.id === nid ? { ...n, enabled: en } : n)) } catch { message.error('更新失败') }
const node = nodes.find((n) => n.id === nid)
if (!node) return
try {
await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en })
setNodes((p) => p.map((n) => (n.id === nid ? { ...n, enabled: en } : n)))
} catch {
toast.error('更新失败')
}
}
async function updateName(nid: string, name: string) {
const trimmed = name.trim()
if (!trimmed) { message.error('节点名称不能为空'); loadData(); return }
try { await api.put(`/nodes/${nid}`, { name: trimmed }); setNodes(p => p.map(n => n.id === nid ? { ...n, name: trimmed } : n)); message.success('名称已更新') }
catch { message.error('名称更新失败'); loadData() }
if (!trimmed) {
toast.error('节点名称不能为空')
void loadData()
return
}
try {
await api.put(`/nodes/${nid}`, { name: trimmed })
setNodes((p) => p.map((n) => (n.id === nid ? { ...n, name: trimmed } : n)))
toast.success('名称已更新')
} catch {
toast.error('名称更新失败')
void loadData()
}
}
const onlineCount = nodes.filter(n => n.online).length
const columns = [
{
title: '', dataIndex: 'online', key: 'online', width: 40,
render: (v: boolean) => (
<div style={{ width: 10, height: 10, borderRadius: '50%', background: v ? '#5B8C5A' : '#B54A4A',
boxShadow: `0 0 6px ${v ? 'rgba(91,140,90,0.3)' : 'rgba(181,74,74,0.3)'}` }} />
),
},
{ title: '名称', dataIndex: 'name', key: 'name', width: 190,
render: (v: string, r: NodeInfo) => <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Input defaultValue={v} size="small" onPressEnter={e => updateName(r.id, e.currentTarget.value)} onBlur={e => { if (e.currentTarget.value !== v) updateName(r.id, e.currentTarget.value) }} />{r.is_origin && <Tag color="warning" style={{ margin: 0 }}>主节点</Tag>}</span> },
{ title: '地址', dataIndex: 'host', key: 'host', width: 130 },
{ title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => <Text code style={{ fontSize: 12 }}>{v}</Text> },
{ title: 'CPU', key: 'cpu', width: 80,
render: (_: any, r: NodeInfo) => r.metrics?.cpu_percent != null
? <Progress percent={Math.round(r.metrics.cpu_percent)} size="small" showInfo={false}
strokeColor={r.metrics.cpu_percent > 80 ? '#B54A4A' : r.metrics.cpu_percent > 60 ? '#C28B3A' : '#C2693A'} />
: <Text type="secondary">--</Text> },
{ title: '内存', key: 'mem', width: 80,
render: (_: any, r: NodeInfo) => r.metrics?.memory_percent != null
? <Progress percent={Math.round(r.metrics.memory_percent)} size="small" showInfo={false}
strokeColor={r.metrics.memory_percent > 80 ? '#B54A4A' : r.metrics.memory_percent > 60 ? '#C28B3A' : '#C2693A'} />
: <Text type="secondary">--</Text> },
{ title: '带宽', key: 'bw', width: 85,
render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null ? <Text style={{ fontSize: 12 }}>{r.metrics.bandwidth_mbps.toFixed(1)} Mbps</Text> : <Text type="secondary">--</Text> },
{ title: '网络入/出', key: 'network', width: 125,
render: (_: any, r: NodeInfo) => r.metrics?.network_in_mbps != null && r.metrics?.network_out_mbps != null ? <Text style={{ fontSize: 12 }}>{r.metrics.network_in_mbps.toFixed(1)} / {r.metrics.network_out_mbps.toFixed(1)} Mbps</Text> : <Text type="secondary">不可用</Text> },
{ title: '观众', key: 'viewers', width: 65,
render: (_: any, r: NodeInfo) => r.metrics?.viewer_count != null ? r.metrics.viewer_count : <Text type="secondary">不可用</Text> },
{ title: '媒体异常', key: 'media', width: 120,
render: (_: any, r: NodeInfo) => r.metrics?.media_metrics_available ? <Text style={{ fontSize: 12 }}>丢帧 {r.metrics.frame_drop_indicators ?? 0} / 丢包 {r.metrics.packet_loss_indicators ?? 0}</Text> : <Text type="secondary">不可用</Text> },
{ title: '连接', key: 'conn', width: 60,
render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : <Text type="secondary">--</Text> },
{ title: '权重', dataIndex: 'weight', key: 'weight', width: 120,
render: (_: number, r: NodeInfo) => <InputNumber min={0} max={1000} value={r.weight} size="small" style={{ width: 80 }} disabled={r.is_origin}
onChange={v => { const val = v || 0; setNodes(p => p.map(n => n.id === r.id ? { ...n, weight: val } : n)); updateWeight(r.id, val) }} /> },
{ title: '启用', dataIndex: 'enabled', key: 'enabled', width: 60,
render: (_: any, r: NodeInfo) => <Switch checked={r.enabled} size="small" disabled={r.is_origin} onChange={v => toggleNode(r.id, v)} /> },
]
const onlineCount = nodes.filter((n) => n.online).length
return (
<div>
<div style={{ marginBottom: 32 }}>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28 }}>边缘节点</Title>
<Text style={{ color: '#949088' }}>管理 CDN 边缘节点集群,监控实时状态</Text>
</div>
<div className="space-y-6">
<PageHeader title="边缘节点" kicker="Edge Nodes" description="管理 CDN 边缘节点集群,监控实时状态" />
{strategy && (
<Row gutter={[24, 24]} style={{ marginBottom: 32 }}>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<Statistic title="均衡策略" value={sL[strategy.strategy] || strategy.strategy} valueStyle={{ fontSize: 20 }} />
<div className="grid gap-4 sm:grid-cols-4">
<StatCard label="均衡策略" value={STRATEGY_LABEL[strategy.strategy] || strategy.strategy} />
<StatCard
label="组播状态"
value={strategy.multicast_available ? '可用' : '不可用'}
tone={strategy.multicast_available ? 'success' : 'default'}
/>
<StatCard
label="在线节点"
value={onlineCount}
description={`共 ${nodes.length} 个节点`}
tone={onlineCount > 0 ? 'success' : 'default'}
/>
<StatCard
label="离线节点"
value={nodes.length - onlineCount}
tone={nodes.length - onlineCount > 0 ? 'destructive' : 'default'}
/>
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<Statistic title="组播状态" value={strategy.multicast_available ? '可用' : '不可用'}
valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<Statistic title="在线节点" value={onlineCount} suffix={<Text style={{ fontSize: 14, color: '#949088' }}>/ {nodes.length}</Text>}
valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<Statistic title="离线节点" value={nodes.length - onlineCount}
valueStyle={{ color: (nodes.length - onlineCount) > 0 ? '#B54A4A' : '#949088', fontSize: 20 }}
prefix={(nodes.length - onlineCount) > 0 ? <WarningOutlined style={{ fontSize: 18 }} /> : undefined} />
</div>
</Col>
</Row>
)}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '8px 8px 0' }}>
<div style={{ padding: '16px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 16 }}>节点列表</Text>
<Button icon={<ReloadOutlined />} onClick={loadData} type="text">刷新</Button>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">节点列表</CardTitle>
<Button variant="ghost" size="icon" onClick={loadData}>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>名称</TableHead>
<TableHead>地址</TableHead>
<TableHead>子网</TableHead>
<TableHead className="w-24">CPU</TableHead>
<TableHead className="w-24">内存</TableHead>
<TableHead className="w-28">网络入/出</TableHead>
<TableHead className="w-16">观众</TableHead>
<TableHead className="w-24">权重</TableHead>
<TableHead className="w-16">启用</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && nodes.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : nodes.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
暂无边缘节点
</TableCell>
</TableRow>
) : (
nodes.map((n) => (
<TableRow key={n.id}>
<TableCell>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ background: n.online ? '#10b981' : '#ef4444' }}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Input
defaultValue={n.name}
className="h-8 w-40"
onBlur={(e) => {
if (e.currentTarget.value !== n.name) updateName(n.id, e.currentTarget.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
}}
/>
{n.is_origin && <Badge variant="secondary">主节点</Badge>}
</div>
<Table dataSource={nodes} columns={columns} rowKey="id" loading={loading}
pagination={false} size="small" scroll={{ x: 1150 }}
locale={{ emptyText: '暂无边缘节点' }} />
</TableCell>
<TableCell className="text-muted-foreground">{n.host}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{n.subnet}</code>
</TableCell>
<TableCell>
{n.metrics?.cpu_percent != null ? (
<Progress value={Math.round(n.metrics.cpu_percent)} className="h-1.5 w-16" />
) : (
<span className="text-muted-foreground">--</span>
)}
</TableCell>
<TableCell>
{n.metrics?.memory_percent != null ? (
<span className="text-xs text-muted-foreground">{n.metrics.memory_percent.toFixed(0)}%</span>
) : (
<span className="text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{n.metrics?.network_in_mbps != null && n.metrics?.network_out_mbps != null
? `${n.metrics.network_in_mbps.toFixed(1)} / ${n.metrics.network_out_mbps.toFixed(1)} Mbps`
: '不可用'}
</TableCell>
<TableCell>
{n.metrics?.viewer_count != null ? n.metrics.viewer_count : <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell>
<Input
type="number"
min={0}
max={1000}
value={n.weight}
disabled={n.is_origin}
className="h-8 w-20"
onChange={(e) => {
const val = Number(e.target.value || 0)
setNodes((p) => p.map((x) => (x.id === n.id ? { ...x, weight: val } : x)))
updateWeight(n.id, val)
}}
/>
</TableCell>
<TableCell>
<Switch checked={n.enabled} disabled={n.is_origin} onCheckedChange={(v) => toggleNode(n.id, v)} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}
+192 -51
View File
@@ -1,18 +1,41 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Button, Input, InputNumber, Select, Space, Spin, Typography, message } from 'antd'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
import { ArrowLeft, ArrowDown, ArrowUp, Trash2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
fetchMediaFiles,
getStream,
updateStream,
type Chapter,
type StreamInfo,
} from '@/api/streams'
import VideoPlayer, { VideoPlayerHandle } from '@/viewer/components/VideoPlayer'
import { PageHeader } from '@/components/page-header'
export default function ReplayEditor() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const videoRef = useRef<HTMLVideoElement>(null)
const playerRef = useRef<VideoPlayerHandle>(null)
const [stream, setStream] = useState<StreamInfo | null>(null)
const [mediaFiles, setMediaFiles] = useState<string[]>([])
const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local')
const [replayFile, setReplayFile] = useState<string>()
const [replayUrl, setReplayUrl] = useState('')
const [previewUrl, setPreviewUrl] = useState<string>()
const [previewError, setPreviewError] = useState('')
const [chapters, setChapters] = useState<Chapter[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -23,16 +46,33 @@ export default function ReplayEditor() {
Promise.all([getStream(streamId), fetchMediaFiles()])
.then(([program, files]) => {
setStream(program)
setReplayFile(program.replay_file)
setReplaySource(program.replay_url ? 'remote' : 'local')
setReplayFile(program.replay_file || undefined)
setReplayUrl(program.replay_url || '')
setChapters(program.chapters || [])
setMediaFiles(files)
})
.catch(() => message.error('无法载入节目回放配置'))
.catch(() => toast.error('无法载入节目回放配置'))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
setPreviewError('')
if (!stream) {
setPreviewUrl(undefined)
return
}
if (replaySource === 'local' && replayFile) {
setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`)
} else if (replaySource === 'remote' && replayUrl.trim()) {
setPreviewUrl(replayUrl.trim())
} else {
setPreviewUrl(undefined)
}
}, [replaySource, replayFile, replayUrl, stream])
const move = (index: number, offset: number) => {
setChapters(current => {
setChapters((current) => {
const target = index + offset
if (target < 0 || target >= current.length) return current
const next = [...current]
@@ -42,74 +82,175 @@ export default function ReplayEditor() {
}
const addChapter = () => {
const time = Math.max(0, Math.round((videoRef.current?.currentTime || 0) * 10) / 10)
setChapters(current => [...current, { title: `章节 ${current.length + 1}`, time }])
const video = playerRef.current?.getVideo()
const time = Math.max(0, Math.round((video?.currentTime || 0) * 10) / 10)
setChapters((current) => [...current, { title: `章节 ${current.length + 1}`, time }])
}
const save = async () => {
if (!stream || !replayFile) return message.warning('请先选择回放文件')
if (!stream) return
const remoteUrl = replayUrl.trim()
if (replaySource === 'local' && !replayFile) return toast.warning('请先选择回放文件')
if (replaySource === 'remote' && !remoteUrl) return toast.warning('请输入外部回放 URL')
if (replaySource === 'remote' && !/^https?:\/\//i.test(remoteUrl)) {
return toast.warning('外部回放 URL 必须以 http:// 或 https:// 开头')
}
setSaving(true)
try {
await updateStream(stream.id, { replay_file: replayFile, chapters })
message.success('回放和章节已保存')
await updateStream(stream.id, {
replay_file: replaySource === 'local' ? replayFile : null,
replay_url: replaySource === 'remote' ? remoteUrl : null,
chapters,
})
toast.success('回放和章节已保存')
} catch {
message.error('保存失败,请检查章节标题、时间和媒体文件')
toast.error('保存失败,请检查回放来源、URL 和章节内容')
} finally {
setSaving(false)
}
}
if (loading) return <div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
if (!stream) return <Alert type="error" message="节目不存在" />
if (loading) {
return (
<div className="flex justify-center py-24">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
}
if (!stream) return <Alert variant="destructive"><AlertTitle>节目不存在</AlertTitle></Alert>
const isEditable = stream.status === 'ended'
return (
<div className="replay-editor" style={{ maxWidth: 980, margin: '0 auto', padding: '8px 0 48px', minWidth: 0 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表</Button>
<Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title>
<Text type="secondary">{stream.title}</Text>
{stream.status !== 'ended' ? (
<Alert style={{ marginTop: 24 }} type="warning" showIcon message="节目结束后才能配置回放和章节" />
<div className="mx-auto max-w-4xl">
<Button variant="ghost" className="mb-5" onClick={() => navigate('/admin/streams')}>
<ArrowLeft className="h-4 w-4" />
返回节目列表
</Button>
<PageHeader title="回放与章节" kicker="Replay" description={stream.title} />
{!isEditable ? (
<Alert className="mt-6" variant="default">
<AlertTitle>不可编辑</AlertTitle>
<AlertDescription>节目结束后才能配置回放和章节</AlertDescription>
</Alert>
) : (
<Space direction="vertical" size={24} style={{ width: '100%', marginTop: 24 }}>
<Select
showSearch
value={replayFile}
placeholder="选择媒体目录中的回放文件"
options={mediaFiles.map(file => ({ value: file, label: file }))}
onChange={setReplayFile}
style={{ width: '100%' }}
<div className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">回放来源</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Tabs value={replaySource} onValueChange={(v) => setReplaySource(v as 'local' | 'remote')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="local">媒体目录文件</TabsTrigger>
<TabsTrigger value="remote">外部回放 URL</TabsTrigger>
</TabsList>
</Tabs>
{replaySource === 'local' ? (
<Select value={replayFile} onValueChange={setReplayFile}>
<SelectTrigger>
<SelectValue placeholder="选择媒体目录中的回放文件" />
</SelectTrigger>
<SelectContent>
{mediaFiles.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">媒体目录中暂无可用视频文件</div>
)}
{mediaFiles.map((file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={replayUrl}
placeholder="https://example.com/path/replay.mp4"
onChange={(e) => setReplayUrl(e.target.value)}
/>
<video
ref={videoRef}
controls
src={replayFile ? `/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}` : undefined}
style={{ width: '100%', maxHeight: 520, borderRadius: 12, background: '#111' }}
)}
{replaySource === 'remote' && (
<Alert variant="default">
<AlertTitle>跨域提示</AlertTitle>
<AlertDescription>外部地址需允许浏览器跨域访问(CORS),播放器将直接流式加载。</AlertDescription>
</Alert>
)}
{previewError && (
<Alert variant="destructive">
<AlertTitle>播放出错</AlertTitle>
<AlertDescription>{previewError}</AlertDescription>
</Alert>
)}
<VideoPlayer
ref={playerRef}
live={false}
replayUrl={previewUrl}
lines={[]}
qualities={[]}
activeLine=""
activeQuality=""
onLineChange={() => {}}
onQualityChange={() => {}}
onError={setPreviewError}
/>
<Button icon={<PlusOutlined />} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节</Button>
<Space direction="vertical" style={{ width: '100%' }}>
<Button variant="outline" onClick={addChapter} disabled={!previewUrl}>
<Plus className="h-4 w-4" />
按当前播放时间添加章节
</Button>
</CardContent>
</Card>
{chapters.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">章节列表</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{chapters.map((chapter, index) => (
<Space key={index} wrap className="replay-chapter-row" style={{ width: '100%', minWidth: 0 }}>
<div key={index} className="flex items-center gap-2">
<Input
value={chapter.title}
placeholder="章节标题"
onChange={event => setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))}
style={{ width: 320, maxWidth: '100%', flex: '1 1 220px' }}
onChange={(e) =>
setChapters((cur) => cur.map((item, i) => (i === index ? { ...item, title: e.target.value } : item)))
}
className="flex-1"
/>
<InputNumber
<Input
type="number"
min={0}
step={0.1}
value={chapter.time}
addonAfter="秒"
onChange={value => setChapters(current => current.map((item, i) => i === index ? { ...item, time: Number(value || 0) } : item))}
onChange={(e) =>
setChapters((cur) => cur.map((item, i) => (i === index ? { ...item, time: Number(e.target.value || 0) } : item)))
}
className="w-28"
/>
<Button icon={<ArrowUpOutlined />} disabled={index === 0} onClick={() => move(index, -1)} />
<Button icon={<ArrowDownOutlined />} disabled={index === chapters.length - 1} onClick={() => move(index, 1)} />
<Button danger icon={<DeleteOutlined />} onClick={() => setChapters(current => current.filter((_, i) => i !== index))} />
</Space>
<Button variant="ghost" size="icon" disabled={index === 0} onClick={() => move(index, -1)}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" disabled={index === chapters.length - 1} onClick={() => move(index, 1)}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setChapters((cur) => cur.filter((_, i) => i !== index))}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</Space>
<Button type="primary" loading={saving} onClick={save}>保存回放与章节</Button>
</Space>
</CardContent>
</Card>
)}
<Button onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存回放与章节'}
</Button>
</div>
)}
</div>
)
-11
View File
@@ -1,11 +0,0 @@
.settings-page { width: 100%; max-width: 960px; min-width: 0; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: #fff; border: 1px solid #ebe5db; border-radius: 12px; }
.profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; }
.quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; }
.quality-row > * { min-width: 0; max-width: 100%; }
@media (max-width: 720px) {
.settings-card { padding: 16px; }
.quality-row { grid-template-columns: 1fr 1fr; }
.quality-row > :first-child, .quality-row > :nth-child(4), .quality-row > :last-child { grid-column: 1 / -1; width: 100%; }
.settings-page .ant-select, .settings-page .ant-input-number { width: 100% !important; }
}
+139 -51
View File
@@ -1,15 +1,27 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd'
import api from '../../api/client'
import './Settings.css'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Trash2, Plus } from 'lucide-react'
import api from '@/api/client'
import { useBrand } from '@/brand/BrandContext'
import { PageHeader } from '@/components/page-header'
type Quality = { label: string; width: number; height: number; bitrate: string }
type Profiles = Record<string, Quality[]>
const strategies = [
const STRATEGIES = [
{ value: 'subnet', label: '子网优先(推荐)' },
{ value: 'weighted', label: '权重均衡' },
{ value: 'round-robin', label: '轮询' },
@@ -35,7 +47,7 @@ export default function SettingsPage() {
setMulticastAvailable(nodes.data.multicast_available)
setProfiles(transcode.data.profiles || {})
})
.catch(() => message.error('系统设置加载失败'))
.catch(() => toast.error('系统设置加载失败'))
}, [])
useEffect(() => {
@@ -44,20 +56,25 @@ export default function SettingsPage() {
setBrandLogoUrl(brand.brand_logo_url)
}, [brand])
function replaceProfile(profile: string, qualities: Quality[]) {
setProfiles(current => Object.assign({}, current, Object.fromEntries([[profile, qualities]])))
}
function updateQuality(profile: string, index: number, patch: Partial<Quality>) {
replaceProfile(profile, profiles[profile].map((quality, i) => i === index ? { ...quality, ...patch } : quality))
setProfiles((current) => ({
...current,
[profile]: current[profile].map((quality, i) => (i === index ? { ...quality, ...patch } : quality)),
}))
}
function addQuality(profile: string) {
replaceProfile(profile, [...profiles[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }])
setProfiles((current) => ({
...current,
[profile]: [...current[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }],
}))
}
function removeQuality(profile: string, index: number) {
replaceProfile(profile, profiles[profile].filter((_, i) => i !== index))
setProfiles((current) => ({
...current,
[profile]: current[profile].filter((_, i) => i !== index),
}))
}
async function saveAll() {
@@ -69,59 +86,130 @@ export default function SettingsPage() {
api.put('/settings/transcode-profiles', { profiles }),
])
await refresh()
message.success('设置已保存')
toast.success('设置已保存')
} catch {
message.error('保存失败,请检查转码参数')
toast.error('保存失败,请检查转码参数')
} finally {
setSaving(false)
}
}
return (
<div className="settings-page">
<Title level={2}>系统设置</Title>
<Text type="secondary">配置分发策略、转码清晰度与平台品牌</Text>
<div className="space-y-6">
<PageHeader title="系统设置" kicker="Settings" description="配置分发策略、转码清晰度与平台品牌" />
<section className="settings-card">
<Title level={4}>负载均衡</Title>
<Form layout="vertical">
<Form.Item label="均衡策略"><Select value={strategy} onChange={setStrategy} options={strategies} /></Form.Item>
<Form.Item label="组播">
<Switch checked={multicastEnabled} onChange={setMulticastEnabled} />
<Text type="secondary"> {multicastAvailable ? '可用' : '不可用,将回退至单播'}</Text>
</Form.Item>
</Form>
</section>
<Card>
<CardHeader>
<CardTitle className="text-base">负载均衡</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>均衡策略</Label>
<Select value={strategy} onValueChange={setStrategy}>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STRATEGIES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<Label>组播</Label>
<p className="text-xs text-muted-foreground">{multicastAvailable ? '可用' : '不可用,将回退至单播'}</p>
</div>
<Switch checked={multicastEnabled} onCheckedChange={setMulticastEnabled} />
</div>
</CardContent>
</Card>
<section className="settings-card">
<Title level={4}>自定义转码配置</Title>
<Card>
<CardHeader>
<CardTitle className="text-base">自定义转码配置</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{Object.entries(profiles).map(([profile, qualities]) => (
<div className="profile-block" key={profile}>
<Text strong>{profile}</Text>
<div key={profile} className="space-y-3">
<div className="flex items-center gap-2">
<span className="font-medium">{profile}</span>
<Separator className="flex-1" />
</div>
{qualities.map((quality, index) => (
<div className="quality-row" key={profile + '-' + index}>
<Input value={quality.label} onChange={e => updateQuality(profile, index, { label: e.target.value })} />
<InputNumber value={quality.width} min={160} max={7680} addonAfter="宽" onChange={v => updateQuality(profile, index, { width: v || 160 })} />
<InputNumber value={quality.height} min={90} max={4320} addonAfter="高" onChange={v => updateQuality(profile, index, { height: v || 90 })} />
<Input value={quality.bitrate} placeholder="2000k" onChange={e => updateQuality(profile, index, { bitrate: e.target.value })} />
<Button danger disabled={qualities.length <= 1} onClick={() => removeQuality(profile, index)}>删除</Button>
<div key={`${profile}-${index}`} className="flex flex-wrap items-center gap-2">
<Input
value={quality.label}
onChange={(e) => updateQuality(profile, index, { label: e.target.value })}
className="w-32"
/>
<Input
type="number"
min={160}
max={7680}
value={quality.width}
onChange={(e) => updateQuality(profile, index, { width: Number(e.target.value || 160) })}
className="w-24"
/>
<Input
type="number"
min={90}
max={4320}
value={quality.height}
onChange={(e) => updateQuality(profile, index, { height: Number(e.target.value || 90) })}
className="w-24"
/>
<Input
value={quality.bitrate}
placeholder="2000k"
onChange={(e) => updateQuality(profile, index, { bitrate: e.target.value })}
className="w-28"
/>
<Button
variant="ghost"
size="icon"
disabled={qualities.length <= 1}
onClick={() => removeQuality(profile, index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
<Button onClick={() => addQuality(profile)}>添加清晰度</Button>
<Button variant="outline" size="sm" onClick={() => addQuality(profile)}>
<Plus className="h-4 w-4" />
添加清晰度
</Button>
</div>
))}
</section>
</CardContent>
</Card>
<section className="settings-card">
<Title level={4}>品牌设置</Title>
<Form layout="vertical">
<Form.Item label="平台名称"><Input value={brandName} onChange={e => setBrandName(e.target.value)} /></Form.Item>
<Form.Item label="标语"><Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} /></Form.Item>
<Form.Item label="Logo 图片地址"><Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} /></Form.Item>
</Form>
</section>
<Card>
<CardHeader>
<CardTitle className="text-base">品牌设置</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>平台名称</Label>
<Input value={brandName} onChange={(e) => setBrandName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>标语</Label>
<Input value={brandTagline} onChange={(e) => setBrandTagline(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Logo 图片地址</Label>
<Input value={brandLogoUrl} onChange={(e) => setBrandLogoUrl(e.target.value)} />
</div>
</CardContent>
</Card>
<Button type="primary" size="large" loading={saving} onClick={saveAll}>保存全部设置</Button>
<Button size="lg" onClick={saveAll} disabled={saving}>
{saving ? '保存中…' : '保存全部设置'}
</Button>
</div>
)
}
+226 -111
View File
@@ -1,151 +1,266 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Form, Input, InputNumber, Button, DatePicker, Typography, message, Space, Alert, Select, Switch, Card, Row, Col } from 'antd'
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { createStream } from '../../api/streams'
import { Copy, ArrowLeft, CheckCircle2, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import axios from 'axios'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { createStream, type RenditionConfig } from '@/api/streams'
const { Title, Text } = Typography
const { TextArea } = Input
const DEFAULT_RENDITIONS: RenditionConfig[] = [
{ label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
{ label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
]
const PRESETS = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow']
export default function StreamForm() {
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [coverUrl, setCoverUrl] = useState('')
const [scheduledAt, setScheduledAt] = useState('')
const [transcodeProfile, setTranscodeProfile] = useState('standard')
const [homepageVisible, setHomepageVisible] = useState(true)
const [remoteControl, setRemoteControl] = useState(false)
const [renditions, setRenditions] = useState<RenditionConfig[]>(DEFAULT_RENDITIONS)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null)
const navigate = useNavigate()
const handleSubmit = async (values: any) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
toast.error('请输入节目标题')
return
}
setLoading(true)
try {
const res = await createStream({
title: values.title,
description: values.description,
cover_url: values.cover_url,
scheduled_at: values.scheduled_at?.toISOString(),
transcode_profile: values.transcode_profile,
homepage_visible: values.homepage_visible,
remote_control_enabled: values.remote_control_enabled,
renditions: values.renditions,
title: title.trim(),
description: description || undefined,
cover_url: coverUrl || undefined,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
transcode_profile: transcodeProfile,
homepage_visible: homepageVisible,
remote_control_enabled: remoteControl,
renditions,
})
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
message.success('直播已创建')
toast.success('直播已创建')
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : undefined
message.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试')
toast.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试')
} finally {
setLoading(false)
}
}
const updateRendition = (index: number, patch: Partial<RenditionConfig>) => {
setRenditions((cur) => cur.map((r, i) => (i === index ? { ...r, ...patch } : r)))
}
if (result) {
return (
<div style={{ maxWidth: 680 }}>
<div style={{ marginBottom: 32 }}>
<CheckCircleOutlined style={{ fontSize: 32, color: '#5B8C5A', marginBottom: 16 }} />
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>直播已创建</Title>
<Text style={{ color: '#5E5A52' }}>将以下信息提供给推流端即可开始直播</Text>
<div className="mx-auto max-w-2xl">
<div className="mb-8">
<CheckCircle2 className="mb-4 h-8 w-8 text-emerald-500" />
<h2 className="text-2xl font-semibold">直播已创建</h2>
<p className="text-sm text-muted-foreground">将以下信息提供给推流端即可开始直播</p>
</div>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card className="mb-6">
<CardContent className="space-y-6 pt-6">
<div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>推流码</Text>
<div style={{ marginTop: 8, padding: '16px 20px', background: '#F7F5F1', borderRadius: 8, fontFamily: 'monospace', fontSize: 14, wordBreak: 'break-all' }}>
{result.stream_key}
</div>
<Label className="text-xs uppercase tracking-wide text-muted-foreground">推流码</Label>
<div className="mt-2 break-all rounded-md bg-muted p-4 font-mono text-sm">{result.stream_key}</div>
</div>
<div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>RTMP 推流地址</Text>
<div style={{ marginTop: 8, padding: '16px 20px', background: '#F7F5F1', borderRadius: 8, fontFamily: 'monospace', fontSize: 14, wordBreak: 'break-all' }}>
{result.rtmp_url}
</div>
</div>
</Space>
<Label className="text-xs uppercase tracking-wide text-muted-foreground">RTMP 推流地址</Label>
<div className="mt-2 break-all rounded-md bg-muted p-4 font-mono text-sm">{result.rtmp_url}</div>
</div>
</CardContent>
</Card>
<Space size={12}>
<Button size="large" icon={<CopyOutlined />} onClick={() => {
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
navigator.clipboard.writeText(`推流码: ${result.stream_key}\nRTMP 地址: ${result.rtmp_url}`)
message.success('已复制')
}}>复制全部信息</Button>
<Button type="primary" size="large" onClick={() => navigate('/admin/streams')}>返回列表</Button>
</Space>
toast.success('已复制')
}}
>
<Copy className="h-4 w-4" />
复制全部信息
</Button>
<Button onClick={() => navigate('/admin/streams')}>返回列表</Button>
</div>
</div>
)
}
return (
<div style={{ maxWidth: 680 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')}
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
</Text>
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{
transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false,
renditions: [
{ label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
{ label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
],
}}>
<Form.Item name="title" label="节目标题"
rules={[{ required: true, message: '请输入节目标题' }]}>
<Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item name="description" label="简介">
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item name="cover_url" label="封面图片 URL">
<Input placeholder="https://..." style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item name="scheduled_at" label="预定开播时间">
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
</Form.Item>
<Form.Item name="transcode_profile" label="预设配置" extra="可直接使用预设,也可在下方逐档调整参数">
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
</Form.Item>
<Form.List name="renditions">
{(fields, { add, remove }) => (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 24 }}>
<Text strong>自定义清晰度与码率</Text>
{fields.map(({ key, name, ...restField }) => (
<Card key={key} size="small" extra={<Button danger type="link" onClick={() => remove(name)}>删除</Button>}>
<Row gutter={[12, 0]}>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'enabled']} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'label']} label="标签" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'width']} label="宽度" rules={[{ required: true }]}><InputNumber min={160} max={7680} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'height']} label="高度" rules={[{ required: true }]}><InputNumber min={90} max={4320} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'fps']} label="FPS"><InputNumber min={1} max={120} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'codec']} label="编码"><Select options={[{ value: 'libx264', label: 'H.264' }, { value: 'libx265', label: 'H.265' }]} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'bitrate']} label="视频码率"><Input placeholder="2500k" /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'crf']} label="CRF"><InputNumber min={0} max={51} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'preset']} label="预设"><Select options={['ultrafast','superfast','veryfast','faster','fast','medium','slow'].map(value => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'audio_bitrate']} label="音频码率"><Input placeholder="128k" /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'keyframe_seconds']} label="关键帧秒数"><InputNumber min={1} max={10} style={{ width: '100%' }} /></Form.Item></Col>
</Row>
</Card>
))}
<Button onClick={() => add({ label: '480p', enabled: true, width: 854, height: 480, fps: 30, codec: 'libx264', bitrate: '1000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 })}>添加清晰度</Button>
</Space>
)}
</Form.List>
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
</Form.Item>
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
创建节目
<div className="mx-auto max-w-2xl">
<Button variant="ghost" className="mb-5" onClick={() => navigate('/admin/streams')}>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Form.Item>
</Form>
<Card>
<CardHeader>
<CardTitle className="text-2xl">创建节目</CardTitle>
<p className="text-sm text-muted-foreground">填写节目信息并预约播出,系统将生成推流码与 RTMP 地址</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="title">节目标题</Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入节目标题" />
</div>
<div className="space-y-2">
<Label htmlFor="description">简介</Label>
<Textarea id="description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="直播内容简介(可选)" />
</div>
<div className="space-y-2">
<Label htmlFor="cover">封面图片 URL</Label>
<Input id="cover" value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="https://..." />
</div>
<div className="space-y-2">
<Label htmlFor="scheduled">预定开播时间</Label>
<Input id="scheduled" type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} />
</div>
<div className="space-y-2">
<Label>预设配置</Label>
<Select value={transcodeProfile} onValueChange={setTranscodeProfile}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">标准多档</SelectItem>
<SelectItem value="bandwidth-saver">带宽节省</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">可直接使用预设,也可在下方逐档调整参数</p>
</div>
<div className="space-y-3">
<Label className="font-medium">自定义清晰度与码率</Label>
{renditions.map((r, index) => (
<div key={index} className="rounded-md border border-border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={r.enabled} onCheckedChange={(v) => updateRendition(index, { enabled: v })} />
<Input
className="h-8 w-24"
value={r.label}
onChange={(e) => updateRendition(index, { label: e.target.value })}
/>
</div>
<Button variant="ghost" size="icon" onClick={() => setRenditions((cur) => cur.filter((_, i) => i !== index))}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs">宽度</Label>
<Input type="number" min={160} max={7680} value={r.width} onChange={(e) => updateRendition(index, { width: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">高度</Label>
<Input type="number" min={90} max={4320} value={r.height} onChange={(e) => updateRendition(index, { height: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">FPS</Label>
<Input type="number" min={1} max={120} value={r.fps} onChange={(e) => updateRendition(index, { fps: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">码率</Label>
<Input value={r.bitrate} onChange={(e) => updateRendition(index, { bitrate: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">编码</Label>
<Select value={r.codec} onValueChange={(v) => updateRendition(index, { codec: v as 'libx264' | 'libx265' })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="libx264">H.264</SelectItem>
<SelectItem value="libx265">H.265</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">预设</Label>
<Select value={r.preset} onValueChange={(v) => updateRendition(index, { preset: v as RenditionConfig['preset'] })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">CRF</Label>
<Input type="number" min={0} max={51} value={r.crf} onChange={(e) => updateRendition(index, { crf: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">音频码率</Label>
<Input value={r.audio_bitrate} onChange={(e) => updateRendition(index, { audio_bitrate: e.target.value })} />
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setRenditions((cur) => [
...cur,
{ label: '480p', enabled: true, width: 854, height: 480, fps: 30, codec: 'libx264', bitrate: '1000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
])
}
>
<Plus className="h-4 w-4" />
添加清晰度
</Button>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<Label>首页公开</Label>
<p className="text-xs text-muted-foreground">公开则出现在首页,关闭则仅私有链接可访问</p>
</div>
<Switch checked={homepageVisible} onCheckedChange={setHomepageVisible} />
</div>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<Label>审计式观众播放控制</Label>
<p className="text-xs text-muted-foreground">仅在明确启用后允许创建限时控制会话</p>
</div>
<Switch checked={remoteControl} onCheckedChange={setRemoteControl} />
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? '创建中…' : '创建节目'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
+161 -65
View File
@@ -1,90 +1,186 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Tag, Space, Typography, message, Tooltip, Popconfirm } from 'antd'
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined, EyeOutlined, EditOutlined } from '@ant-design/icons'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '待开播' },
live: { color: 'green', label: '直播中' },
ended: { color: 'default', label: '已结束' },
cancelled: { color: 'red', label: '已取消' },
}
import { Plus, Play, Square, Trash2, Link, Eye, Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '@/api/streams'
import { formatDateTime } from '@/lib/status'
import { PageHeader } from '@/components/page-header'
import { StatusBadge } from '@/components/status-badge'
export default function StreamList() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => { loadData() }, [])
useEffect(() => {
void loadData()
}, [])
async function loadData() {
setLoading(true)
try { setStreams(await fetchStreams(true)) } catch { message.error('加载失败') }
try {
setStreams(await fetchStreams(true))
} catch {
toast.error('加载失败')
}
setLoading(false)
}
async function handleDelete(id: number) {
try { await deleteStream(id); message.success('已删除'); loadData() } catch { message.error('删除失败') }
try {
await deleteStream(id)
toast.success('已删除')
void loadData()
} catch {
toast.error('删除失败')
}
async function handleGoLive(id: number) {
try { await goLive(id); message.success('已开播'); loadData() } catch { message.error('操作失败') }
}
async function handleEnd(id: number) {
try { await endStream(id); message.success('已结束'); loadData() } catch { message.error('操作失败') }
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={(statusMap[s] || {}).color}>{(statusMap[s] || { label: s }).label}</Tag> },
{ title: '推流码', dataIndex: 'stream_key', key: 'stream_key', width: 120,
render: (v: string) => <Text code style={{ fontSize: 12 }}>{v.slice(0, 12)}...</Text> },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 160 },
{
title: '操作', key: 'actions', width: 240,
render: (_: any, r: StreamInfo) => (
<Space size={4}>
<Tooltip title="复制推流码">
<Button size="small" icon={<LinkOutlined />}
onClick={() => { if (r.stream_key) navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
</Tooltip>
{r.status !== 'live' ? (
<Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip>
) : (
<Tooltip title="结束"><Button size="small" danger icon={<StopOutlined />} onClick={() => handleEnd(r.id)} /></Tooltip>
)}
<Tooltip title="观看"><Button size="small" icon={<EyeOutlined />} onClick={() => window.open(`/watch/${r.id}`, '_blank')} /></Tooltip>
<Tooltip title="编辑"><Button size="small" icon={<EditOutlined />} onClick={() => navigate(`/admin/streams/${r.id}/edit`)} /></Tooltip>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Tooltip title="删除"><Button size="small" danger icon={<DeleteOutlined />} /></Tooltip>
</Popconfirm>
</Space>
),
},
]
async function handleGoLive(id: number) {
try {
await goLive(id)
toast.success('已开播')
void loadData()
} catch {
toast.error('操作失败')
}
}
async function handleEnd(id: number) {
try {
await endStream(id)
toast.success('已结束')
void loadData()
} catch {
toast.error('操作失败')
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
<div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
<div className="space-y-6">
<PageHeader title="节目列表" kicker="Streams" description="管理直播、排期与回放节目">
<Button onClick={() => navigate('/admin/streams/new')}>
<Plus className="h-4 w-4" />
创建节目
</Button>
</PageHeader>
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base">全部节目</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14">ID</TableHead>
<TableHead>标题</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-32">推流码</TableHead>
<TableHead className="w-40">创建时间</TableHead>
<TableHead className="w-52 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : streams.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
暂无节目,创建第一个节目吧
</TableCell>
</TableRow>
) : (
streams.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{s.id}</TableCell>
<TableCell className="max-w-0 truncate font-medium">{s.title}</TableCell>
<TableCell>
<StatusBadge status={s.status} />
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.stream_key?.slice(0, 12)}…</code>
</TableCell>
<TableCell className="text-muted-foreground">{formatDateTime(s.created_at)}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="复制推流码"
onClick={() => {
if (s.stream_key) navigator.clipboard.writeText(s.stream_key)
toast.success('已复制')
}}
>
<Link className="h-4 w-4" />
</Button>
{s.status !== 'live' ? (
<Button variant="ghost" size="icon" title="开播" onClick={() => handleGoLive(s.id)}>
<Play className="h-4 w-4 text-emerald-500" />
</Button>
) : (
<Button variant="ghost" size="icon" title="结束" onClick={() => handleEnd(s.id)}>
<Square className="h-4 w-4 text-destructive" />
</Button>
)}
<Button variant="ghost" size="icon" title="观看" onClick={() => window.open(`/watch/${s.id}`, '_blank')}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="编辑" onClick={() => navigate(`/admin/streams/${s.id}/edit`)}>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除?</AlertDialogTitle>
<AlertDialogDescription>删除后节目与回放配置将不可恢复。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(s.id)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB', padding: '8px 8px 0',
}}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
locale={{ emptyText: '暂无节目,创建第一个节目吧' }} />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from 'react'
import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function SwaggerPage() {
useEffect(() => {
// Load Swagger UI assets from CDN and mount against the backend OpenAPI spec.
const css = document.createElement('link')
css.rel = 'stylesheet'
css.href = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css'
document.head.appendChild(css)
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js'
script.onload = () => {
const swaggerUI = (window as any).SwaggerUIBundle
if (!swaggerUI || !document.getElementById('swagger-container')) return
swaggerUI({
url: '/openapi.json',
dom_id: '#swagger-container',
deepLinking: true,
presets: [swaggerUI.presets.apis],
layout: 'BaseLayout',
})
}
document.body.appendChild(script)
return () => {
document.head.removeChild(css)
document.body.removeChild(script)
}
}, [])
return (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div>
<h2 className="text-2xl font-semibold">API 文档</h2>
<p className="text-sm text-muted-foreground">基于 OpenAPI 规范的接口调试与说明(Swagger UI)</p>
</div>
<Button variant="outline" asChild>
<a href="/docs" target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
原生 Swagger
</a>
</Button>
</div>
<Card className="overflow-hidden">
<CardHeader className="border-b border-border">
<CardTitle className="text-base">Swagger UI</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div id="swagger-container" className="min-h-[70vh]" />
</CardContent>
</Card>
</div>
)
}
+4 -1
View File
@@ -27,6 +27,7 @@ export interface StreamInfo {
multicast_enabled: boolean
created_at: string
replay_file?: string
replay_url?: string
chapters: Chapter[]
transcode_profile: string
renditions?: RenditionConfig[] | null
@@ -44,7 +45,8 @@ export interface StreamCreateReq {
description?: string
cover_url?: string
scheduled_at?: string
replay_file?: string
replay_file?: string | null
replay_url?: string | null
chapters?: Chapter[]
transcode_profile?: string
renditions?: RenditionConfig[]
@@ -63,6 +65,7 @@ export interface WatchInfo {
description?: string
scheduled_at?: string
replay_url?: string
replay_local_url?: string
chapters: Chapter[]
remote_control_enabled: boolean
}
+27 -13
View File
@@ -13,23 +13,37 @@ export interface SystemMetrics {
}
interface SystemMetricsResponse {
cpu: { load_percent: number }
memory: { total: number; available: number; used_percent: number }
disk: { total: number; free: number; used_percent: number }
network: { bytes_recv: number; bytes_sent: number }
cpu?: { load_percent?: number }
memory?: { total?: number; available?: number; used_percent?: number }
disk?: { total?: number; free?: number; used_percent?: number }
network?: { bytes_recv?: number; bytes_sent?: number }
cpu_percent?: number
memory_percent?: number
disk_percent?: number
disk_total_bytes?: number
disk_used_bytes?: number
network_received_bytes?: number
network_sent_bytes?: number
}
const numberOrZero = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
export async function getSystemMetrics(): Promise<SystemMetrics> {
const { data } = await api.get<SystemMetricsResponse>('/settings/system-metrics')
const memoryTotal = numberOrZero(data.memory?.total)
const memoryAvailable = numberOrZero(data.memory?.available)
const diskTotal = numberOrZero(data.disk?.total ?? data.disk_total_bytes)
const diskUsed = numberOrZero(data.disk_used_bytes ?? (diskTotal - numberOrZero(data.disk?.free)))
return {
cpu_percent: data.cpu.load_percent,
memory_percent: data.memory.used_percent,
memory_total_bytes: data.memory.total,
memory_used_bytes: Math.max(0, data.memory.total - data.memory.available),
disk_percent: data.disk.used_percent,
disk_total_bytes: data.disk.total,
disk_used_bytes: Math.max(0, data.disk.total - data.disk.free),
network_received_bytes: data.network.bytes_recv,
network_sent_bytes: data.network.bytes_sent,
cpu_percent: numberOrZero(data.cpu?.load_percent ?? data.cpu_percent),
memory_percent: numberOrZero(data.memory?.used_percent ?? data.memory_percent),
memory_total_bytes: memoryTotal,
memory_used_bytes: Math.max(0, memoryTotal - memoryAvailable),
disk_percent: numberOrZero(data.disk?.used_percent ?? data.disk_percent),
disk_total_bytes: diskTotal,
disk_used_bytes: Math.max(0, diskUsed),
network_received_bytes: numberOrZero(data.network?.bytes_recv ?? data.network_received_bytes),
network_sent_bytes: numberOrZero(data.network?.bytes_sent ?? data.network_sent_bytes),
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
kicker?: string
children?: ReactNode
className?: string
}
export function PageHeader({ title, description, kicker, children, className }: PageHeaderProps) {
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="space-y-2">
{kicker && (
<p className="text-xs font-bold uppercase tracking-[0.14em] text-primary">{kicker}</p>
)}
<h2 className="font-display text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">
{title}
</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
type Tone = 'default' | 'success' | 'warning' | 'destructive'
const toneClass: Record<Tone, string> = {
default: 'text-muted-foreground',
success: 'text-emerald-500',
warning: 'text-amber-500',
destructive: 'text-destructive',
}
interface StatCardProps {
label: string
value: ReactNode
icon?: LucideIcon
description?: string
tone?: Tone
}
export function StatCard({ label, value, icon: Icon, description, tone = 'default' }: StatCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{Icon && <Icon className={cn('h-4 w-4', toneClass[tone])} />}
</div>
<p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
</CardContent>
</Card>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const STATUS_META: Record<string, { label: string; dot: string }> = {
pending: { label: '待开播', dot: 'bg-amber-500' },
live: { label: '直播中', dot: 'bg-emerald-500' },
ended: { label: '已结束', dot: 'bg-zinc-500' },
cancelled: { label: '已取消', dot: 'bg-red-500' },
}
export function statusLabel(status: string): string {
return STATUS_META[status]?.label ?? status
}
export function StatusBadge({ status, className }: { status: string; className?: string }) {
const meta = STATUS_META[status]
if (!meta) return <Badge variant="outline">{status}</Badge>
return (
<Badge variant="outline" className={cn('gap-1.5 font-normal text-muted-foreground', className)}>
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}
</Badge>
)
}
+139
View File
@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-warm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+157
View File
@@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
-80
View File
@@ -1,80 +0,0 @@
:root {
--bg-page: #F9F7F4;
--bg-surface: #FFFFFF;
--bg-surface-raised: #FAFAF8;
--bg-muted: #F3F0EC;
--bg-brand: #C2693A;
--bg-brand-light: #F6EEE5;
--bg-dark: #1A1814;
--text-primary: #1C1A17;
--text-secondary: #5E5A52;
--text-tertiary: #949088;
--text-inverse: #F9F7F4;
--border-default: #EBE5DB;
--border-light: #F3EFE9;
--border-brand: #D4A782;
--shadow-sm: 0 1px 2px rgba(28,26,23,0.03);
--shadow-md: 0 4px 12px rgba(28,26,23,0.04);
--shadow-lg: 0 12px 32px rgba(28,26,23,0.06);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-sans: "Source Serif 4", "Noto Serif SC", Georgia, serif;
--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
--space-9: 96px;
--space-10: 128px;
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.65;
color: var(--text-primary);
background: var(--bg-page);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.02em;
}
::selection {
background: #EAD7C8;
color: #1C1A17;
}
@media (max-width: 600px) {
.replay-editor { overflow: hidden; }
.replay-editor video { max-width: 100%; }
.replay-chapter-row { display: flex !important; }
.replay-chapter-row .ant-input-number { flex: 1 1 140px; max-width: 100%; }
}
+126
View File
@@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 45 8% 11%;
--foreground: 42 24% 93%;
--card: 45 7% 14%;
--card-foreground: 42 24% 93%;
--popover: 45 7% 15%;
--popover-foreground: 42 24% 93%;
--primary: 43 90% 62%;
--primary-foreground: 40 30% 12%;
--secondary: 45 7% 18%;
--secondary-foreground: 42 24% 93%;
--muted: 45 7% 17%;
--muted-foreground: 43 12% 62%;
--accent: 45 7% 19%;
--accent-foreground: 42 24% 93%;
--destructive: 16 72% 60%;
--destructive-foreground: 42 24% 93%;
--border: 45 8% 21%;
--input: 45 8% 22%;
--ring: 43 90% 62%;
--radius: 0.625rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: 'rlig' 1, 'calt' 1;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
}
@layer utilities {
.font-display {
font-family: Georgia, 'Times New Roman', ui-serif, serif;
}
.shadow-warm {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 12px 32px rgba(0, 0, 0, 0.22);
}
.shadow-warm-lg {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 22px 58px rgba(0, 0, 0, 0.28);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 8px;
}
}
/* Markdown content rendered by the viewer (description / chapters intro). */
.md-content {
font-size: 0.875rem;
line-height: 1.7;
color: hsl(var(--foreground));
}
.md-content h1,
.md-content h2,
.md-content h3,
.md-content h4 {
font-weight: 600;
margin: 1rem 0 0.5rem;
}
.md-content h1 {
font-size: 1.25rem;
}
.md-content h2 {
font-size: 1.125rem;
}
.md-content h3 {
font-size: 1rem;
}
.md-content p {
margin: 0.5rem 0;
}
.md-content ul,
.md-content ol {
padding-left: 1.25rem;
margin: 0.5rem 0;
}
.md-content ul {
list-style: disc;
}
.md-content ol {
list-style: decimal;
}
.md-content a {
color: hsl(var(--primary));
text-decoration: underline;
}
.md-content code {
background: hsl(var(--muted));
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
.md-content pre {
background: hsl(var(--muted));
padding: 0.75rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.md-content blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 0.75rem;
color: hsl(var(--muted-foreground));
}
/* Swagger UI sits inside a dark card; keep its own theme independent. */
#swagger-container .swagger-ui {
font-family: inherit;
}
+41
View File
@@ -0,0 +1,41 @@
export type StreamStatus = 'pending' | 'live' | 'ended' | 'cancelled'
export const STATUS_LABEL: Record<string, string> = {
pending: '待开播',
live: '直播中',
ended: '已结束',
cancelled: '已取消',
}
export const STATUS_BADGE: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
live: 'default',
ended: 'outline',
cancelled: 'destructive',
}
export const STATUS_ORDER: Record<string, number> = {
live: 0,
pending: 1,
ended: 2,
cancelled: 3,
}
export function formatDateTime(value?: string | null): string {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function formatBytes(value = 0): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let amount = Math.max(0, value)
let index = 0
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024
index += 1
}
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './global.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
-28
View File
@@ -1,28 +0,0 @@
.app-route-transition,
.admin-page-transition {
min-height: 100%;
animation: page-fade-slide-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: opacity, transform;
}
.admin-page-transition {
transform-origin: top center;
}
@keyframes page-fade-slide-in {
from {
opacity: 0;
transform: translateY(14px) scale(0.995);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.app-route-transition,
.admin-page-transition {
animation: none;
}
}
-78
View File
@@ -1,78 +0,0 @@
import type { ThemeConfig } from 'antd'
const theme: ThemeConfig = {
token: {
fontFamily: `"Source Serif 4", "Noto Serif SC", Georgia, serif`,
fontSize: 15,
lineHeight: 1.65,
colorPrimary: '#C2693A',
colorSuccess: '#5B8C5A',
colorWarning: '#C28B3A',
colorError: '#B54A4A',
colorInfo: '#6B8CA8',
colorTextBase: '#1C1A17',
colorTextSecondary: '#5E5A52',
colorTextTertiary: '#949088',
colorBgBase: '#F9F7F4',
colorBgContainer: '#FFFFFF',
colorBgLayout: '#F9F7F4',
colorBgElevated: '#FAFAF8',
colorBorder: '#EBE5DB',
colorBorderSecondary: '#F3EFE9',
colorFill: '#F3F0EC',
colorFillSecondary: '#F7F5F1',
borderRadius: 6,
borderRadiusLG: 10,
borderRadiusSM: 4,
wireframe: false,
fontWeightStrong: 600,
controlHeight: 40,
controlHeightLG: 48,
controlHeightSM: 32,
paddingLG: 32,
paddingMD: 24,
paddingSM: 16,
boxShadow: '0 1px 2px rgba(28,26,23,0.03)',
boxShadowSecondary: '0 4px 12px rgba(28,26,23,0.04)',
boxShadowTertiary: '0 12px 32px rgba(28,26,23,0.06)',
},
components: {
Layout: {
headerBg: '#FFFFFF',
headerHeight: 60,
headerPadding: '0 48px',
siderBg: '#FFFFFF',
bodyBg: '#F9F7F4',
},
Menu: {
itemBg: 'transparent',
itemSelectedBg: '#F3F0EC',
itemSelectedColor: '#1C1A17',
itemActiveBg: '#F7F5F1',
itemColor: '#5E5A52',
itemHeight: 44,
},
Card: {
paddingLG: 28,
borderRadiusLG: 10,
},
Button: {
borderRadius: 6,
fontWeight: 500,
primaryShadow: '0 2px 6px rgba(194,105,58,0.1)',
},
Table: {
headerBg: '#F7F5F1',
headerColor: '#5E5A52',
rowHoverBg: '#F5F3F0',
borderColor: '#EBE5DB',
},
Tag: { borderRadiusSM: 4 },
Modal: { borderRadiusLG: 12, paddingLG: 28 },
Tabs: { inkBarColor: '#C2693A', itemActiveColor: '#C2693A', itemHoverColor: '#D4A782' },
Select: { borderRadius: 6 },
Input: { borderRadius: 6 },
},
}
export default theme
@@ -0,0 +1,48 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Components } from 'react-markdown'
const components: Components = {
// 外部链接一律新窗口打开,避免观众被带离播放页
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}
/**
* 安全的 Markdown 渲染。react-markdown 默认不输出 raw HTML,
* 简介中的 HTML 会被当作纯文本处理,不会执行,天然免疫 XSS。
*/
export default function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div className={className || 'md-content'}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
)
}
/**
* 把 Markdown 摘要为纯文本,供列表卡片截断展示。
* 只做常见的标记剥离,足够生成一行预览。
*/
export function mdToText(source: string | undefined | null): string {
if (!source) return ''
return source
.replace(/```[\s\S]*?```/g, (block) => {
const firstLine = block.split('\n')[1]?.trim() ?? ''
return firstLine || ''
})
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^>\s?/gm, '')
.replace(/^[-*+]\s+/gm, '')
.replace(/^\d+[.)]\s+/gm, '')
.replace(/(\*\*|__|\*|_|~~)/g, '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
@@ -0,0 +1,829 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import Hls from 'hls.js'
import { Loader2, Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react'
import type { Chapter, LineInfo, QualityInfo } from '@/api/streams'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
/** Move the playhead by a delta, used by the keyboard shortcuts. */
seekBy: (seconds: number) => void
play: () => Promise<void>
pause: () => void
getVideo: () => HTMLVideoElement | null
}
interface VideoPlayerProps {
live: boolean
replayUrl?: string
lines: LineInfo[]
qualities: QualityInfo[]
activeLine: string
activeQuality: string
autoQuality?: string
chapters?: Chapter[]
onLineChange: (line: string) => void
onQualityChange: (quality: string) => void
onError?: (message: string) => void
}
// A replay can run for hours, so dragging a scrubber to a precise offset is
// hopeless. Arrow keys jump by a fixed step and are the primary way to navigate;
// the multipliers make it bearable to cross a long recording.
const SEEK_STEP_SECONDS = 5
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return '00:00'
const value = Math.max(0, Math.floor(seconds))
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const secs = value % 60
return hours > 0
? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
: `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
/** "2.5Mbps" / "600kbps" for the source tier, whose rate is only known at runtime. */
function formatBitrate(value: number) {
if (!Number.isFinite(value) || value <= 0) return ''
return value >= 1000000 ? `${(value / 1000000).toFixed(1)}Mbps` : `${Math.round(value / 1000)}kbps`
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function VideoPlayer(
{
live,
replayUrl,
lines,
qualities,
activeLine,
activeQuality,
autoQuality,
chapters = [],
onLineChange,
onQualityChange,
onError,
},
ref,
) {
const rootRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [loading, setLoading] = useState(false)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [buffered, setBuffered] = useState(0)
const [volume, setVolume] = useState(1)
const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
// Playback rate. Replays are often watched faster; live has no use for it.
const [speed, setSpeed] = useState(1)
// Autoplay gate: a browser that refuses to start playback on its own can only
// be unblocked by a real click, so the prompt has to be impossible to miss.
// `ready` keeps it hidden until there is actually something to play.
const [needsGesture, setNeedsGesture] = useState(false)
const [ready, setReady] = useState(false)
// Set when autoplay only succeeded after muting, so the click that clears the
// gate knows to restore sound.
const [mutedAttempt, setMutedAttempt] = useState(false)
// Measured size/bitrate of the source ("原画") stream. Nothing about it is known
// up front, and reading it off a different rendition would mislabel the option,
// so it is only filled in while the source playlist is the one playing.
const [sourceMeta, setSourceMeta] = useState('')
const sourceMetaRef = useRef({ isSource: false })
const sourceBitrateRef = useRef(0)
// Controls start visible, then fade after inactivity. Hiding them purely on
// :hover meant entering fullscreen showed a bare video with no controls until
// the pointer happened to move.
const [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null)
// Jump the playhead by a delta, clamped to the media.
//
// Clamped rather than rejected so a jump that runs past an end lands on the
// boundary and still visibly does something, instead of being refused by the
// browser and appearing to be a dead key.
const seekBy = useCallback((delta: number) => {
const video = videoRef.current
if (!video) return
const upper = Number.isFinite(video.duration) && video.duration > 0
? video.duration
: video.currentTime + delta
video.currentTime = Math.min(Math.max(0, video.currentTime + delta), upper)
}, [])
const changeVolume = (value: number) => {
const video = videoRef.current
if (!video) return
video.volume = value
video.muted = value === 0
if (value > 0) setMutedAttempt(false)
setVolume(value)
}
const changeSpeed = (rate: number) => {
const video = videoRef.current
setSpeed(rate)
if (video) video.playbackRate = rate
}
// Refresh the source-option label from what is actually decoding. A component
// function so both the once-registered media listeners and the source-switching
// effect can call it.
const measureSource = useCallback(() => {
const video = videoRef.current
if (!video || !sourceMetaRef.current.isSource) return
setSourceMeta(
[formatBitrate(sourceBitrateRef.current), video.videoWidth && video.videoHeight ? `${video.videoWidth}×${video.videoHeight}` : '']
.filter(Boolean)
.join(' · '),
)
}, [])
// Attempt autoplay, silently muting first if the browser only allows muted
// playback. Rejections other than NotAllowedError (AbortError from a source
// swap, etc.) leave the element alone — the 'canplay' handler covers those.
const attemptAutoplay = useCallback(async (video: HTMLVideoElement) => {
try {
await video.play()
setNeedsGesture(false)
} catch (error) {
if ((error as DOMException)?.name === 'NotAllowedError' && !video.muted) {
video.muted = true
setMuted(true)
setMutedAttempt(true)
try {
// Muted playback is granted, so the stream starts — but silently. Keep
// the prompt up until the viewer interacts, both to offer the sound and
// so the muted state is never mistaken for a broken stream.
await video.play()
setNeedsGesture(true)
return
} catch {
// fall through to the gate
}
} else if ((error as DOMException)?.name !== 'NotAllowedError') {
return
}
if (!video.paused) return
setNeedsGesture(true)
}
}, [])
const unmute = useCallback(() => {
const video = videoRef.current
if (!video) return
video.muted = false
if (video.volume === 0) video.volume = 1
setMuted(false)
setMutedAttempt(false)
}, [])
// The click that unblocks playback also has to unmute — otherwise the viewer
// sees a "muted" state they never asked for.
const resolveGate = () => {
const video = videoRef.current
setNeedsGesture(false)
if (!video) return
if (mutedAttempt) unmute()
void video.play().catch(() => setNeedsGesture(true))
}
// Muted autoplay always succeeds, so "we muted it to get playing at all" is a
// silent state, and the stream sounds broken until someone notices the small
// speaker icon. This deliberately restores sound from ONE place — the video's
// own click handler below — rather than a window-level gesture listener: a
// window listener fires on pointerdown, which runs before React's click handler,
// so by then mutedAttempt was already false and the click toggled playback off
// instead of unmuting. One owner, one ordering.
useImperativeHandle(
ref,
() => ({
seek(seconds) {
// Live playlists only retain a short window, so seeking far back lands
// outside the buffer and stalls. Clamp to what is actually available.
const video = videoRef.current
if (!video) return
const target = Math.max(0, seconds)
if (live && video.buffered.length) {
const start = video.buffered.start(0)
if (target < start) {
video.currentTime = start
return
}
}
video.currentTime = target
},
seekBy,
async play() {
await videoRef.current?.play()
},
pause() {
videoRef.current?.pause()
},
getVideo() {
return videoRef.current
},
}),
[seekBy],
)
// 基础事件监听
useEffect(() => {
const video = videoRef.current
if (!video) return
const update = () => {
setCurrentTime(video.currentTime || 0)
setDuration(Number.isFinite(video.duration) ? video.duration : 0)
const end = video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0
setBuffered(end)
}
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onWaiting = () => setLoading(true)
const onPlaying = () => {
setLoading(false)
setNeedsGesture(false)
setMuted(video.muted)
}
const onCanPlay = () => {
setLoading(false)
setReady(true)
// Data is in and the element still is not playing: autoplay either lost the
// race against the previous source or was refused, so prompt the viewer.
if (video.paused) setNeedsGesture(true)
}
const onVolumeChange = () => {
setVolume(video.volume)
setMuted(video.muted)
if (!video.muted) setMutedAttempt(false)
}
const onResize = () => measureSource()
const onErrorEvent = () => {
setLoading(false)
const error = video.error
let msg = '视频播放出错'
if (error) {
switch (error.code) {
case 1:
msg = '视频加载中止'
break
case 2:
msg = '网络错误'
break
case 3:
msg = '视频解码失败'
break
case 4:
msg = '视频格式不支持或未找到'
break
default:
msg = `未知错误 (${error.code})`
}
if (error.message) msg += `: ${error.message}`
}
onError?.(msg)
}
video.addEventListener('timeupdate', update)
video.addEventListener('durationchange', update)
video.addEventListener('progress', update)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
video.addEventListener('volumechange', onVolumeChange)
video.addEventListener('waiting', onWaiting)
video.addEventListener('playing', onPlaying)
video.addEventListener('canplay', onCanPlay)
video.addEventListener('resize', onResize)
video.addEventListener('loadedmetadata', onResize)
video.addEventListener('error', onErrorEvent)
return () => {
video.removeEventListener('timeupdate', update)
video.removeEventListener('durationchange', update)
video.removeEventListener('progress', update)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
video.removeEventListener('volumechange', onVolumeChange)
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
video.removeEventListener('canplay', onCanPlay)
video.removeEventListener('resize', onResize)
video.removeEventListener('loadedmetadata', onResize)
video.removeEventListener('error', onErrorEvent)
}
}, [onError, measureSource])
// 全屏监听
useEffect(() => {
const listener = () => {
setFullscreen(document.fullscreenElement === rootRef.current)
setControlsVisible(true)
}
document.addEventListener('fullscreenchange', listener)
// Older WebKit (iOS Safari) reports video fullscreen through its own event.
const video = videoRef.current
const onWebkitBegin = () => setFullscreen(true)
const onWebkitEnd = () => setFullscreen(false)
video?.addEventListener('webkitbeginfullscreen', onWebkitBegin)
video?.addEventListener('webkitendfullscreen', onWebkitEnd)
return () => {
document.removeEventListener('fullscreenchange', listener)
video?.removeEventListener('webkitbeginfullscreen', onWebkitBegin)
video?.removeEventListener('webkitendfullscreen', onWebkitEnd)
}
}, [])
// Fade the controls out after a few idle seconds, but never while paused —
// a paused player with no controls looks broken.
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current)
hideTimerRef.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setControlsVisible(false)
}, 3000)
}, [])
const revealControls = useCallback(() => {
setControlsVisible(true)
scheduleHide()
}, [scheduleHide])
useEffect(() => {
if (!playing) setControlsVisible(true)
return () => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current)
}
}, [playing])
// The exact playlist to load, resolved from the line/quality selection.
//
// Derived here as a *value* rather than read from `lines` inside the effect,
// because the effect tears down and rebuilds the media source whenever its
// dependencies change. Callers such as the replay editor pass fresh array
// literals on every render (`lines={[]}`), so depending on the arrays meant any
// re-render — typing a chapter title, adding a chapter — was treated as a source
// change and restarted playback from the beginning. A string cannot do that.
const liveSource = useMemo(() => {
if (!live) return ''
const line = lines.find((item) => item.node_id === activeLine)
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find((item) => item.label === label)
// Every quality URL is a single-rendition playlist, so this label is the only
// thing selecting a rendition; line.url is a bare /hls/<key>/index.m3u8 master
// that the transcoder never writes.
return quality?.url || line?.qualities?.[0]?.url || line?.url || line?.unicast_fallback || ''
}, [live, lines, activeLine, activeQuality, autoQuality])
// Whether this label is the source ("原画") rendition, which is advertised
// without dimensions and measured from the media instead.
const liveSourceIsSource = useMemo(() => {
if (!live) return false
const label = activeQuality === 'auto' ? autoQuality : activeQuality
return qualities.some((item) => (!item.width || !item.height) && item.label === label)
}, [live, qualities, activeQuality, autoQuality])
// 切换源并自动播放
useEffect(() => {
const video = videoRef.current
if (!video) return
const controller = new AbortController()
hlsRef.current?.destroy()
hlsRef.current = null
video.removeAttribute('src')
video.load()
setLoading(Boolean(live || replayUrl))
// A new source has to prove itself playable again before autoplay is judged,
// and the old source's measurements must not be attributed to it.
setReady(false)
setMutedAttempt(false)
setSourceMeta('')
sourceMetaRef.current = { isSource: false }
sourceBitrateRef.current = 0
onError?.('')
if (!live && replayUrl) {
video.src = replayUrl
video.load()
void attemptAutoplay(video)
} else if (live) {
// Resolved outside the effect so that re-renders with fresh prop arrays do
// not look like a source change; see liveSource above.
const source = liveSource
sourceMetaRef.current = { isSource: liveSourceIsSource }
const hasQualities = qualities.length > 0
const onFallbackLoaded = () => {
setLoading(false)
sourceBitrateRef.current = 0
measureSource()
}
if (!source) {
setLoading(false)
onError?.('当前没有可用的播放地址')
} else if (Hls.isSupported()) {
// Buffering is the thing to avoid; chase latency only after that. hls.js
// measures latency to its own target rather than to the segment edge, so
// anything set here (liveSyncDurationCount, rate) has to be interpreted in
// its terms or the player and the UI disagree about what "behind" means.
//
// Deliberately NOT enabled, all for the same reason — each pulls the target
// latency down to a single 4s segment, so playback races the encoder and
// re-buffers at the end of every segment:
// lowLatencyMode: true target drops to the last *partial* segment
// liveSyncDurationCount: 1 target = exactly one segment
// maxLiveSyncPlaybackRate speeds playback up to 1.5x to hold that
// backBufferLength: 0 nothing behind the playhead to survive a stall
// The default three-segment (~12s) cushion is what keeps playback smooth.
const hls = new Hls({
enableWorker: true,
// Keeps recent segments available locally, so a brief stall is served
// from the buffer instead of going back to the network.
backBufferLength: 30,
})
hlsRef.current = hls
hls.loadSource(source)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setLoading(false)
// For live, anchors playback to the end of the sliding window so a new
// viewer starts at the live edge instead of the oldest retained segment.
hls.startLoad(live ? -1 : 0)
void attemptAutoplay(video)
})
// A single-rendition playlist has exactly one level and no name, so the
// selection made above is what decides whether this is the source tier.
// Only refresh the measured bitrate/size here; do not re-answer that.
hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
sourceBitrateRef.current = hls.levels?.[data.level]?.bitrate || 0
measureSource()
})
// The API is the authority on which rendition a label is; a manifest with no
// level names cannot override it. This only covers a fallback playlist whose
// rendition list we never received.
hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => {
if (data.details.live && !hasQualities) onFallbackLoaded()
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setLoading(false)
onError?.('播放流加载失败,请切换线路或稍后重试')
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.load()
void video
.play()
.then(onFallbackLoaded)
.catch(() => {
if (video.readyState >= 3) onFallbackLoaded()
else setNeedsGesture(true)
})
} else {
setLoading(false)
onError?.('当前浏览器不支持 HLS 播放')
}
}
return () => {
controller.abort()
hlsRef.current?.destroy()
hlsRef.current = null
}
// `liveSource` stands in for the line/quality selection (it is derived from
// them), and `qualities.length` stands in for the array itself — depending on
// `lines`/`qualities` directly would restart playback on every re-render of a
// caller that passes fresh literals.
}, [live, liveSource, liveSourceIsSource, onError, replayUrl, attemptAutoplay, measureSource, qualities.length])
const togglePlayback = () => {
const video = videoRef.current
if (!video) return
if (video.paused) {
setNeedsGesture(false)
void video.play().catch(() => setNeedsGesture(true))
return
}
// Live cannot be paused: there is no seekable timeline behind the playhead, so
// pausing just parks the viewer further and further behind a stream that keeps
// running. Resuming is still offered (autoplay may have been blocked), but
// nothing here ever stops a live stream.
if (!live) video.pause()
}
// Keyboard navigation.
//
// Dragging a scrubber is unusable on a multi-hour replay: one pixel is tens of
// seconds, so a precise position cannot be hit at all. Arrow keys move by a
// fixed step, with Shift for a coarse jump, which is the practical way to
// navigate a long recording.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Never fight a form control: the replay editor has chapter title and time
// inputs, and typing a number there must not scrub the video.
const target = event.target as HTMLElement | null
const tag = target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return
if (event.metaKey || event.ctrlKey || event.altKey) return
// Anything longer than this in an hour-long video is the difference between
// useful and useless, so scale the coarse jump with the duration.
const duration = videoRef.current?.duration
const fine = SEEK_STEP_SECONDS
const coarse = Number.isFinite(duration) && (duration ?? 0) > 600 ? 30 : SEEK_STEP_SECONDS * 2
const step = event.shiftKey ? coarse : fine
switch (event.key) {
case 'ArrowLeft':
seekBy(-step)
setControlsVisible(true)
break
case 'ArrowRight':
seekBy(step)
setControlsVisible(true)
break
case ' ': {
// Space scrolls the page by default, which is never what is wanted.
event.preventDefault()
togglePlayback()
break
}
case 'j':
case 'J':
seekBy(-coarse)
break
case 'l':
case 'L':
seekBy(coarse)
break
case 'ArrowUp':
event.preventDefault()
changeVolume(Math.min(1, (videoRef.current?.volume ?? 1) + 0.1))
break
case 'ArrowDown':
event.preventDefault()
changeVolume(Math.max(0, (videoRef.current?.volume ?? 1) - 0.1))
break
default:
return
}
scheduleHide()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [seekBy, scheduleHide])
const toggleMute = () => {
const video = videoRef.current
if (!video) return
video.muted = !video.muted
if (!video.muted && video.volume === 0) video.volume = 1
}
const seek = (value: number) => {
const video = videoRef.current
if (video) video.currentTime = value
}
// 原画 是发布方原始流:分辨率与码率只有正在播放它时才测得到,所以先按"原始画质"占位。
const qualityOptions = qualities.map((quality) => {
const meta =
quality.width && quality.height
? `${quality.label} · ${quality.width}×${quality.height}`
: [quality.label, quality.label === activeQuality ? sourceMeta : ''].filter(Boolean).join(' · ')
return { value: quality.label, label: meta === quality.label ? `${quality.label} · 原始画质` : meta }
})
const autoLabel = qualityOptions.find((option) => option.value === autoQuality)?.label || autoQuality
const toggleFullscreen = async () => {
// Prefer the Fullscreen API on the player wrapper. It gives the true
// edge-to-edge experience and, unlike F11, also works on mobile browsers and
// when the page is embedded in an iframe (where F11's browser chrome is
// unavailable anyway). F11 remains available as the user's own shortcut.
const root = rootRef.current
if (!root) return
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
} else if (typeof root.requestFullscreen === 'function') {
await root.requestFullscreen()
} else {
// iOS Safari has no element Fullscreen API; fall back to native video FS.
const video = videoRef.current as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
video?.webkitEnterFullscreen?.()
}
} catch {
// Denied (e.g. permissions policy) — leave the current view as-is.
}
}
return (
<div
ref={rootRef}
onMouseMove={revealControls}
onMouseLeave={() => {
if (!videoRef.current?.paused) setControlsVisible(false)
}}
onTouchStart={revealControls}
className="group relative aspect-video w-full overflow-hidden rounded-lg bg-black [[:fullscreen]_&]:aspect-auto [[:fullscreen]_&]:h-screen [[:fullscreen]_&]:rounded-none [&:fullscreen]:aspect-auto [&:fullscreen]:h-screen [&:fullscreen]:w-screen [&:fullscreen]:rounded-none"
>
<video
ref={videoRef}
playsInline
preload="metadata"
// While the player is muted only because autoplay demanded it, a click on
// the picture means "give me the sound", not "pause". Handling it here
// rather than with a window-level listener keeps the two in a known order:
// the restore happens first, then this returns without toggling playback.
onClick={() => (mutedAttempt ? unmute() : togglePlayback())}
className="h-full w-full"
/>
{/* Persistent live badge — the stream state should be readable without
hovering, since "is this live?" is the first question a viewer asks. */}
{loading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div>
)}
{/* Autoplay was refused: nothing plays until the viewer clicks, so this has
to be louder than the spinner. Sits under the control bar (z-10 vs z-20)
to keep the quality/line pickers reachable while it is up. */}
{needsGesture && ready && (
<button
type="button"
onClick={resolveGate}
className="absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-4 bg-black/70 text-white backdrop-blur-[2px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
>
<span className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full border-2 border-white/60 bg-white/15">
<Play className="h-9 w-9 translate-x-0.5 fill-current" />
</span>
<span className="text-xl font-semibold">{playing ? '已静音播放' : '点击播放'}</span>
<span className="max-w-xs text-sm text-white/70">
{mutedAttempt ? '点击开启声音' : '浏览器阻止了自动播放,请点击开始'}
</span>
</button>
)}
{/* Playing but only because we muted it: say so loudly, since a silent
stream reads as broken. Any interaction restores the sound too. */}
{mutedAttempt && !needsGesture && (
<button
type="button"
onClick={unmute}
className="absolute left-3 bottom-16 z-10 flex cursor-pointer items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs font-medium text-white backdrop-blur transition-colors hover:bg-black/85"
>
<VolumeX className="h-4 w-4" />
已静音播放,点击开启声音
</button>
)}
{/* Controls */}
<div
className={
'absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 to-transparent p-3 transition-opacity duration-200 ' +
(controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0')
}
>
{/* VOD gets a scrubber; live gets a status strip — there is no seekable
timeline in a live stream, and rendering one implies you can rewind. */}
{!live && duration > 0 && (
<div className="relative mb-2 flex h-3 items-center">
<div className="absolute h-1 w-full rounded bg-white/20" />
<div
className="absolute h-1 rounded bg-white/40"
style={{ width: `${duration ? Math.min(100, (buffered / duration) * 100) : 0}%` }}
/>
<input
aria-label="播放进度"
type="range"
min={0}
max={duration || 0}
step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={(e) => seek(Number(e.target.value))}
className="absolute h-3 w-full cursor-pointer appearance-none bg-transparent"
/>
{chapters.map((chapter, index) => (
<button
key={`${chapter.time}-${index}`}
className="absolute h-2 w-2 -translate-x-1/2 rounded-full bg-primary"
style={{ left: `${Math.min(100, (chapter.time / duration) * 100)}%` }}
title={chapter.title}
onClick={() => seek(chapter.time)}
/>
))}
</div>
)}
<div className="flex items-center gap-3 text-white">
{/* Play/pause mirrors the real state, so the icon is never a lie. A live
stream has nothing to pause, so once it is running this control has
no action left and is not rendered at all — showing either icon would
misrepresent something: a pause icon promises a pause that does not
happen, and a play icon makes a playing stream look stopped. It
reappears if playback stops, which is the one case it can still act. */}
{(!live || !playing) && (
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : playing ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</button>
)}
{live ? (
<span className="flex items-center gap-1.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
LIVE
</span>
) : (
<span className="text-xs tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
)}
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'}>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<label className="flex items-center gap-2">
<input
aria-label="音量"
type="range"
min={0}
max={1}
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => changeVolume(Number(e.target.value))}
className="h-1 w-16 cursor-pointer"
/>
</label>
{/* Playback rate is a replay tool: speeding a live stream up would just
race it further ahead of the encoder's buffer. */}
{!live && (
<select
aria-label="播放速度"
value={speed}
onChange={(e) => changeSpeed(Number(e.target.value))}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs tabular-nums"
>
{SPEED_OPTIONS.map((rate) => (
<option key={rate} value={rate} className="bg-zinc-900">
{rate}×
</option>
))}
</select>
)}
<span className="flex-1" />
{live && lines.length > 1 && (
<select
aria-label="播放线路"
value={activeLine}
onChange={(e) => onLineChange(e.target.value)}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs"
>
{lines.map((line) => (
<option key={line.node_id} value={line.node_id} className="bg-zinc-900">
{line.node_name}
</option>
))}
</select>
)}
{live && qualityOptions.length > 0 && (
<select
aria-label="清晰度"
value={activeQuality}
onChange={(e) => onQualityChange(e.target.value)}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs"
>
<option value="auto" className="bg-zinc-900">
自动{autoQuality ? ` (${autoLabel})` : ''}
</option>
{qualityOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-zinc-900">
{option.label}
</option>
))}
</select>
)}
<button type="button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? '退出全屏' : '全屏'}>
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
</div>
</div>
</div>
)
})
export default VideoPlayer
+93 -101
View File
@@ -1,26 +1,42 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Skeleton } from 'antd'
import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
import './viewer.css'
import { Search, Video } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { fetchStreams, type StreamInfo } from '@/api/streams'
import { useBrand } from '@/brand/BrandContext'
import { mdToText } from '@/viewer/components/Markdown'
import { STATUS_ORDER } from '@/lib/status'
import { cn } from '@/lib/utils'
const FILTERS = [
['all', '全部'],
['live', '直播中'],
['pending', '待开播'],
['ended', '回放'],
] as const
export default function HomePage() {
const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const [filter, setFilter] = useState<string>('all')
const navigate = useNavigate()
useEffect(() => {
let active = true
let pollTimer: ReturnType<typeof setInterval> | undefined
const load = () => fetchStreams()
.then(data => { if (active) setStreams(data) })
const load = () =>
fetchStreams()
.then((data) => {
if (active) setStreams(data)
})
.catch(() => {})
.finally(() => { if (active) setLoading(false) })
.finally(() => {
if (active) setLoading(false)
})
const startPolling = () => {
if (!pollTimer) pollTimer = setInterval(load, 10000)
}
@@ -31,7 +47,7 @@ export default function HomePage() {
void load()
const events = new EventSource('/api/streams/catalog/events')
events.addEventListener('catalog', event => {
events.addEventListener('catalog', (event) => {
if (!active) return
try {
const data = JSON.parse((event as MessageEvent<string>).data)
@@ -54,154 +70,130 @@ export default function HomePage() {
}
}, [])
const sorted = [...streams].sort((a, b) => {
const o: Record<string, number> = { live: 0, pending: 1, ended: 2, cancelled: 3 }
return (o[a.status] ?? 9) - (o[b.status] ?? 9)
})
const visibleStreams = sorted.filter(stream => {
const sorted = [...streams].sort((a, b) => (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9))
const liveCount = sorted.filter((s) => s.status === 'live').length
const visibleStreams = sorted.filter((stream) => {
const matchesFilter = filter === 'all' || stream.status === filter
const haystack = `${stream.title} ${stream.description || ''}`.toLowerCase()
const haystack = `${stream.title} ${mdToText(stream.description)}`.toLowerCase()
return matchesFilter && haystack.includes(query.trim().toLowerCase())
})
return (
<div className="viewer-shell">
<header className="viewer-hero">
<nav className="viewer-nav" aria-label="主导航">
<span>{brand.brand_name}</span>
<button className="viewer-button viewer-button--ghost" type="button" onClick={() => document.getElementById('programmes')?.scrollIntoView()}>
节目目录
</button>
</nav>
<div className="viewer-eyebrow">LIVE PROGRAMME</div>
{brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name}
className="viewer-logo" />
)}
<h1 className="viewer-title">
{brand.brand_name}
</h1>
{brand.brand_tagline && (
<p className="viewer-tagline">
{brand.brand_tagline}
</p>
)}
<div className="min-h-screen bg-background">
<header className="border-b border-border/60">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<div className="flex items-center gap-2.5">
<span className="h-4 w-4 rounded-[5px] bg-gradient-to-br from-primary to-amber-600 shadow-sm" />
<span className="font-display text-lg font-semibold tracking-tight">{brand.brand_name}</span>
</div>
<div className="flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs text-muted-foreground">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
{liveCount} 个直播中
</div>
</div>
</header>
<main className="viewer-main" id="programmes">
<div className="viewer-section-heading">
<h2>
直播频道
</h2>
<span>
{sorted.length} 个节目
</span>
<main className="mx-auto max-w-6xl px-6 pb-12 pt-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-baseline gap-2.5">
<h1 className="font-display text-2xl font-semibold tracking-tight">节目</h1>
<span className="text-sm tabular-nums text-muted-foreground">{sorted.length} 个</span>
</div>
<div className="viewer-directory-tools">
<label className="viewer-search">
<SearchOutlined aria-hidden="true" />
<input
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:w-64">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={event => setQuery(event.target.value)}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索节目标题或简介"
aria-label="搜索节目"
className="h-10 bg-secondary pl-9 shadow-none"
/>
</label>
<div className="viewer-filters" aria-label="按节目状态筛选" role="group">
{[
['all', '全部'],
['live', '直播中'],
['pending', '待开播'],
['ended', '回放'],
].map(([value, label]) => (
</div>
<div className="flex gap-1.5">
{FILTERS.map(([value, label]) => (
<button
key={value}
type="button"
className={`viewer-button viewer-filter${filter === value ? ' is-active' : ''}`}
aria-pressed={filter === value}
onClick={() => setFilter(value)}
className={cn(
'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
filter === value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground',
)}
>
{label}
</button>
))}
</div>
</div>
</div>
{loading ? (
<div className="stream-grid" aria-label="正在加载直播节目">
{[1,2,3,4,5,6].map(i => (
<div className="stream-card stream-skeleton" key={i}>
<div className="stream-cover" />
<div className="stream-copy">
<Skeleton active paragraph={{ rows: 2 }} title={{ width: '60%' }} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-warm">
<Skeleton className="aspect-video rounded-none" />
<div className="space-y-2 p-4">
<Skeleton className="h-4 w-3/5" />
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
) : visibleStreams.length === 0 ? (
<div className="viewer-empty">
<span className="viewer-empty-icon"><VideoCameraOutlined /></span>
<h3>暂无直播</h3>
<p>当前没有正在播出或排期的直播节目</p>
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border py-20 text-center">
<Video className="h-10 w-10 text-muted-foreground" />
<h3 className="font-display text-lg font-semibold">暂无直播</h3>
<p className="text-sm text-muted-foreground">当前没有正在播出或排期的直播节目</p>
</div>
) : (
<div className="stream-grid">
{visibleStreams.map(s => {
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visibleStreams.map((s) => {
const isLive = s.status === 'live'
const isPending = s.status === 'pending'
return (
<article key={s.id}>
<button
key={s.id}
type="button"
className={`stream-card${isLive ? ' is-live' : ''}`}
onClick={() => navigate(`/watch/${s.id}`)}
className="group overflow-hidden rounded-xl border border-border/60 bg-card text-left shadow-warm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-warm-lg"
>
{/* Cover */}
<div className="stream-cover" style={s.cover_url ? { backgroundImage: `url(${s.cover_url})` } : undefined}>
{!s.cover_url && (
<VideoCameraOutlined style={{ color: 'rgba(249,247,244,0.08)', fontSize: 56 }} />
)}
{/* Status badge */}
<div
className="relative flex aspect-video items-center justify-center bg-secondary"
style={s.cover_url ? { backgroundImage: `url(${s.cover_url})`, backgroundSize: 'cover', backgroundPosition: 'center' } : undefined}
>
{!s.cover_url && <Video className="h-12 w-12 text-muted-foreground/30" />}
{isLive && (
<div className="stream-status live">
<span className="live-dot" />直播中
<div className="absolute left-2 top-2">
<StatusBadge status="live" className="border-transparent bg-background/80 backdrop-blur" />
</div>
)}
{isPending && (
<div className="stream-status pending">
待开播
<div className="absolute left-2 top-2">
<StatusBadge status="pending" className="border-transparent bg-background/80 backdrop-blur" />
</div>
)}
{!isLive && !isPending && (
<div>
<span className="stream-status ended">
{s.status === 'ended' ? '已结束' : '已取消'}
</span>
<div className="absolute left-2 top-2">
<StatusBadge status={s.status} className="border-transparent bg-background/80 backdrop-blur" />
</div>
)}
</div>
{/* Info */}
<div className="stream-copy">
<h3>
{s.title}
</h3>
<p>
{s.description || '暂无简介'}
</p>
<div className="space-y-1.5 p-4">
<h3 className="font-medium leading-snug">{s.title}</h3>
<p className="line-clamp-2 text-sm text-muted-foreground">{mdToText(s.description) || '暂无简介'}</p>
</div>
</button>
</article>
)
})}
</div>
)}
</main>
<footer className="viewer-footer">
<span>
<footer className="border-t border-border/60 py-6 text-center text-sm text-muted-foreground">
{brand.brand_name} &copy; {new Date().getFullYear()}
</span>
</footer>
</div>
)
+210 -165
View File
@@ -1,8 +1,12 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js'
import { ArrowLeft, Radio } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import VideoPlayer, { type VideoPlayerHandle } from '@/viewer/components/VideoPlayer'
import Markdown from '@/viewer/components/Markdown'
import {
getStreamStatusUrl,
getWatchUrls,
@@ -11,17 +15,15 @@ import {
sendViewerHeartbeat,
type WatchInfo,
type LineInfo,
} from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
import './viewer.css'
const { Text } = Typography
} from '@/api/streams'
import { useBrand } from '@/brand/BrandContext'
export default function WatchPage() {
const { brand } = useBrand()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const videoRef = useRef<HTMLVideoElement>(null)
const playerRef = useRef<VideoPlayerHandle>(null)
const getVideo = useCallback(() => playerRef.current?.getVideo() ?? null, [])
const [info, setInfo] = useState<WatchInfo | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
@@ -35,25 +37,36 @@ export default function WatchPage() {
const [remoteAdjusted, setRemoteAdjusted] = useState(false)
const [showPlaybackStats, setShowPlaybackStats] = useState(false)
const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' })
const hlsRef = useRef<Hls | null>(null)
const viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine)
const currentLine = info?.lines.find((line) => line.node_id === activeLine)
const generateSessionId = () =>
window.crypto?.randomUUID?.().replace(/-/g, '') ?? `${Date.now()}${Math.random().toString(16).slice(2)}`
// Keyed by stream: the stored id records which programme it belongs to, so
// switching streams starts a fresh session instead of colliding with the old one.
useEffect(() => {
const storageKey = 'viewer-session-id'
const storedSessionId = window.localStorage.getItem(storageKey)
const sessionId = storedSessionId || (window.crypto?.randomUUID?.().replace(/-/g, '') ?? `${Date.now()}${Math.random().toString(16).slice(2)}`)
if (!storedSessionId) window.localStorage.setItem(storageKey, sessionId)
const streamId = Number(id)
if (!Number.isInteger(streamId) || streamId <= 0) return
const storageKey = `viewer-session:${streamId}`
const stored = window.localStorage.getItem(storageKey)
const sessionId = stored || generateSessionId()
if (!stored) window.localStorage.setItem(storageKey, sessionId)
viewerSessionRef.current = sessionId.slice(0, 64)
}, [])
// Drop the pre-fix global key: it bound every programme to whichever the viewer
// watched first, which is what made heartbeats come back 409 and polling 404.
window.localStorage.removeItem('viewer-session-id')
}, [id])
useEffect(() => {
const streamId = Number(id)
if (!Number.isInteger(streamId) || streamId <= 0 || !info) return
const heartbeat = async () => {
const video = videoRef.current
const bufferedSeconds = video && video.buffered.length
const video = getVideo()
const bufferedSeconds =
video && video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0
try {
@@ -67,8 +80,15 @@ export default function WatchPage() {
error: playbackError || null,
})
setRemoteAdjusted(result.remote_adjusted)
} catch {
// Telemetry must never interrupt playback.
} catch (error) {
// 409 means this session id belongs to a different programme — a leftover
// from before the per-stream storage key, or from another tab. Claim a new
// one so heartbeats and command polling recover instead of failing forever.
if ((error as { response?: { status?: number } })?.response?.status === 409) {
const fresh = generateSessionId()
viewerSessionRef.current = fresh.slice(0, 64)
window.localStorage.setItem(`viewer-session:${streamId}`, fresh)
}
}
}
@@ -96,15 +116,22 @@ export default function WatchPage() {
}
}
void heartbeat()
void pollCommand()
// The heartbeat must land before the first poll: the backend only knows a
// session once it has been told about one, and polling first gets a 404 for a
// session that is about to exist. Chaining them removes that guaranteed
// first-request failure without adding a timer.
const startPolling = async () => {
await heartbeat()
await pollCommand()
}
void startPolling()
const heartbeatTimer = window.setInterval(heartbeat, 10000)
const commandTimer = window.setInterval(pollCommand, 3000)
return () => {
window.clearInterval(heartbeatTimer)
window.clearInterval(commandTimer)
}
}, [id, info, activeLine, activeQuality, playbackError, currentLine?.node_name])
}, [id, info, activeLine, activeQuality, playbackError, currentLine?.node_name, getVideo])
useEffect(() => {
const streamId = Number(id)
@@ -128,12 +155,12 @@ export default function WatchPage() {
}
setInfo(next)
setLoadError('')
setActiveLine(current => {
if (current && next.lines.some(line => line.node_id === current)) return current
setActiveLine((current) => {
if (current && next.lines.some((line) => line.node_id === current)) return current
return next.lines.length > 0 ? autoSelectLine(next.lines).node_id : ''
})
setActiveQuality(current => {
if (current && next.qualities.some(quality => quality.label === current)) return current
setActiveQuality((current) => {
if (current && next.qualities.some((quality) => quality.label === current)) return current
return next.qualities[0]?.label ?? ''
})
} catch {
@@ -172,88 +199,42 @@ export default function WatchPage() {
}, [id])
function autoSelectLine(lines: LineInfo[]): LineInfo {
const enabled = lines.filter(l => l.weight > 0)
const enabled = lines.filter((l) => l.weight > 0)
if (enabled.length === 0) return lines[0]
const total = enabled.reduce((s, l) => s + l.weight, 0)
let r = Math.random() * total
for (const line of enabled) { r -= line.weight; if (r <= 0) return line }
for (const line of enabled) {
r -= line.weight
if (r <= 0) return line
}
return enabled[enabled.length - 1]
}
useEffect(() => {
const video = videoRef.current
if (!info || !video) return
setPlaybackError('')
const previousTime = Number.isFinite(video.currentTime) ? video.currentTime : 0
const wasPaused = video.paused
const restorePlaybackState = () => {
if (previousTime > 0 && Number.isFinite(video.duration)) {
video.currentTime = Math.min(previousTime, Math.max(0, video.duration - 0.1))
}
if (!wasPaused) video.play().catch(() => {})
}
hlsRef.current?.destroy()
hlsRef.current = null
video.removeAttribute('src')
video.load()
if (info.replay_url && info.status !== 'live') {
video.src = info.replay_url
video.load()
return
}
if (info.status !== 'live' || !activeLine) return
const line = info.lines.find(l => l.node_id === activeLine)
if (!line) {
setPlaybackError('所选播放线路不可用')
return
}
const selectedLabel = activeQuality === 'auto' ? (autoQuality || line.qualities?.[0]?.label) : activeQuality
const quality = selectedLabel ? line.qualities?.find(q => q.label === selectedLabel) : line.qualities?.[0]
const source = quality?.url || line.url || line.unicast_fallback
if (!source) {
setPlaybackError('当前节目没有可用的播放地址')
return
}
if (Hls.isSupported()) {
const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
hi.loadSource(source)
hi.attachMedia(video)
hi.on(Hls.Events.MANIFEST_PARSED, restorePlaybackState)
hi.on(Hls.Events.ERROR, (_e, detail) => {
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
})
hlsRef.current = hi
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.addEventListener('loadedmetadata', restorePlaybackState, { once: true })
video.load()
} else {
setPlaybackError('当前浏览器不支持此播放格式')
}
return () => {
hlsRef.current?.destroy()
hlsRef.current = null
}
}, [activeLine, activeQuality, autoQuality, info])
useEffect(() => {
if (!info?.qualities.length) return
setAutoQuality(current => current || info.qualities[0].label)
setAutoQuality((current) => current || info.qualities[0].label)
}, [info])
useEffect(() => {
const video = videoRef.current
if (!video || !info || activeQuality !== 'auto') return
const video = getVideo()
if (!video || !info) return
const qualityLabels = info.qualities.map(quality => quality.label)
const qualityLabels = info.qualities.map((quality) => quality.label)
// The auto ladder only applies while the viewer is on "auto". Auto-selected
// renditions stay internal state so the UI can still report "自动 (720p)"
// instead of masquerading as a manual switch.
// `info.qualities` runs from the highest tier down, source ("原画") first, so
// stepping an index up walks toward lower quality and down toward higher.
// The source carries no dimensions, so this list must not be re-sorted.
const isAuto = activeQuality === 'auto'
const lowerQuality = () => {
setAutoQuality(current => {
setAutoQuality((current) => {
const index = Math.max(0, qualityLabels.indexOf(current))
return qualityLabels[Math.min(index + 1, qualityLabels.length - 1)] || current
})
}
const higherQuality = () => {
setAutoQuality(current => {
setAutoQuality((current) => {
const index = Math.max(0, qualityLabels.indexOf(current))
return qualityLabels[Math.max(index - 1, 0)] || current
})
@@ -264,9 +245,9 @@ export default function WatchPage() {
if (bufferStartedRef.current && now - playbackStartedRef.current > 10000) {
const duration = now - bufferStartedRef.current
bufferEventsRef.current.push({ at: now, duration })
bufferEventsRef.current = bufferEventsRef.current.filter(event => now - event.at <= 10000)
bufferEventsRef.current = bufferEventsRef.current.filter((event) => now - event.at <= 10000)
const accumulatedBuffering = bufferEventsRef.current.reduce((total, event) => total + event.duration, 0)
if (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3) {
if (isAuto && (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3)) {
lowerQuality()
bufferEventsRef.current = []
}
@@ -275,11 +256,24 @@ export default function WatchPage() {
}
const onWaiting = () => {
const now = Date.now()
if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return
// Don't count the initial tune-in as a stall: the player sits in "waiting"
// while the live edge is still being established, which is not a network
// problem worth degrading quality over.
if (
!isAuto ||
!playbackStartedRef.current ||
video.currentTime <= 0 ||
now - playbackStartedRef.current <= 20000 ||
bufferStartedRef.current
) {
return
}
bufferStartedRef.current = now
}
const recoveryTimer = window.setInterval(() => {
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
if (video.paused || video.seeking || video.readyState < 3) return
// Only step quality back up after a genuinely clean minute of playback.
if (!bufferEventsRef.current.length) higherQuality()
}, 60000)
video.addEventListener('waiting', onWaiting)
@@ -289,10 +283,10 @@ export default function WatchPage() {
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
}
}, [activeQuality, info])
}, [activeQuality, info, getVideo])
useEffect(() => {
const video = videoRef.current
const video = getVideo()
if (!video) return
const updateStats = () => {
const buffered = video.buffered.length
@@ -309,106 +303,157 @@ export default function WatchPage() {
updateStats()
const timer = window.setInterval(updateStats, 1000)
return () => window.clearInterval(timer)
}, [activeLine, activeQuality, autoQuality])
}, [activeLine, activeQuality, autoQuality, getVideo])
function jumpToChapter(time: number) {
if (!videoRef.current) return
videoRef.current.currentTime = time
videoRef.current.play().catch(() => {})
playerRef.current?.seek(time)
void playerRef.current?.play().catch(() => {})
}
useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand])
useEffect(() => {
document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name
}, [info, brand])
if (loading) return (
<div style={{ minHeight: '100vh', background: '#1A1814', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
if (!info) return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F9F7F4' }}>
<Empty description={loadError || '未找到该直播'}>
}
if (!info) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-8 text-center">
<h2 className="text-lg font-medium">{loadError || '未找到该直播'}</h2>
<Button onClick={() => window.location.reload()}>重新加载</Button>
</Empty>
</div>
)
}
const statusLabel = info.status === 'live' ? 'LIVE' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status
const statusLabel =
info.status === 'live' ? 'LIVE' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status
return (
<div className="watch-shell">
<header className="watch-header">
<div className="watch-heading">
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
onClick={() => navigate('/')}>{brand.brand_name}</Button>
<span className="watch-divider" />
<Text className="watch-title">{info.title}</Text>
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
{statusLabel}
</Tag>
</div>
<div className="watch-options">
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
<Tag style={{ margin: 0 }}>清晰度:{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality || '不可用'}</Tag>
{remoteAdjusted && <Tag color="gold" style={{ margin: 0 }}>播放由管理员临时调整</Tag>}
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-card">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-6 py-3">
<Button variant="ghost" size="sm" onClick={() => navigate('/')}>
<ArrowLeft className="h-4 w-4" />
{brand.brand_name}
</Button>
<span className="text-muted-foreground">/</span>
<span className="font-medium">{info.title}</span>
<Badge variant={info.status === 'live' ? 'default' : 'secondary'}>{statusLabel}</Badge>
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Badge>
<Badge variant="outline">
清晰度:{activeQuality === 'auto' ? `自动${autoQuality ? `(${autoQuality})` : ''}` : activeQuality || '不可用'}
</Badge>
{remoteAdjusted && <Badge className="bg-amber-600">播放由管理员临时调整</Badge>}
<span className="flex items-center gap-1.5 text-muted-foreground">
<Radio className={`h-3.5 w-3.5 ${info.multicast_available ? 'text-emerald-500' : ''}`} />
{info.multicast_available ? '组播' : '单播'}
</Text>
<Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small"
options={[
{ value: 'auto', label: `自动(${autoQuality || '检测中'})` },
...info.qualities.map(q => ({ value: q.label, label: q.label })),
]} placeholder="清晰度" />
<Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
prefix={<WifiOutlined />} placeholder="线路" />
</span>
</div>
</div>
</header>
<main className="watch-stage">
<div className="watch-content">
<div className="watch-frame">
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
<main className="mx-auto max-w-6xl px-6 py-6">
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div>
<div
className="overflow-hidden rounded-lg border border-border bg-black"
onContextMenu={(e) => {
e.preventDefault()
setShowPlaybackStats((v) => !v)
}}
>
<VideoPlayer
ref={playerRef}
live={info.status === 'live'}
replayUrl={info.status !== 'live' ? info.replay_url || info.replay_local_url : undefined}
lines={info.lines}
qualities={info.qualities}
activeLine={activeLine}
activeQuality={activeQuality}
autoQuality={autoQuality}
chapters={info.chapters}
onLineChange={setActiveLine}
onQualityChange={setActiveQuality}
onError={setPlaybackError}
/>
{showPlaybackStats && (
<section className="playback-stats" aria-label="播放统计信息">
<div><span>播放状态</span><strong>{videoRef.current?.paused ? '暂停' : '播放中'}</strong></div>
<div><span>线路</span><strong>{currentLine?.node_name || '不可用'}</strong></div>
<div><span>清晰度</span><strong>{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality}</strong></div>
<div><span>分辨率</span><strong>{playbackStats.resolution}</strong></div>
<div><span>时间码</span><strong>{playbackStats.currentTime.toFixed(1)} 秒</strong></div>
<div><span>缓冲</span><strong>{playbackStats.buffered.toFixed(1)} 秒</strong></div>
<div><span>丢帧</span><strong>{playbackStats.droppedFrames}</strong></div>
<div className="playback-stats-url"><span>播放地址</span><strong>{videoRef.current?.currentSrc || '加载中'}</strong></div>
<section className="grid grid-cols-2 gap-x-6 gap-y-1 border-t border-border p-4 text-sm sm:grid-cols-4">
{[
// Live is never paused (the player refuses to), so reporting a
// paused state there would only ever describe a stall.
['播放状态', getVideo()?.paused ? (info.status === 'live' ? '缓冲中' : '暂停') : '播放中'],
['线路', currentLine?.node_name || '不可用'],
['清晰度', activeQuality === 'auto' ? `自动${autoQuality ? `(${autoQuality})` : ''}` : activeQuality],
['分辨率', playbackStats.resolution],
['时间码', `${playbackStats.currentTime.toFixed(1)} 秒`],
['缓冲', `${playbackStats.buffered.toFixed(1)} 秒`],
['丢帧', String(playbackStats.droppedFrames)],
['播放地址', getVideo()?.currentSrc || '加载中'],
// Read from the ref, not a local copy: the session id is
// created in an effect (after the first render) and replaced
// whenever the backend reports this one belongs to another
// programme, so a value captured during render would be blank
// on load and stale after a rebind.
['会话标识', viewerSessionRef.current || '初始化中'],
].map(([label, value]) => (
<div key={label}>
<span className="text-muted-foreground">{label}</span>
<div className="truncate font-medium">{value}</div>
</div>
))}
</section>
)}
</div>
{playbackError && (
<div className="watch-waiting">
<Alert type="error" showIcon message={playbackError} />
<div className="mt-3">
<Alert variant="destructive">
<AlertTitle>播放出错</AlertTitle>
<AlertDescription>{playbackError}</AlertDescription>
</Alert>
</div>
)}
{!playbackError && info.status !== 'live' && !info.replay_url && (
<div className="watch-waiting">
{info.status === 'pending' && <Spin size="large" />}
<Text>{info.status === 'ended' ? '节目已结束,暂无回放' : '等待主播开播...'}</Text>
{!playbackError && info.status !== 'live' && !info.replay_url && !info.replay_local_url && (
<div className="mt-3 rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
{info.status === 'ended' ? '节目已结束,暂无回放' : '等待主播开播...'}
</div>
)}
{!playbackError && info.status === 'live' && info.lines.length === 0 && (
<div className="watch-waiting"><Text>直播中,暂无可用播放线路</Text></div>
<div className="mt-3 rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
直播中,暂无可用播放线路
</div>
)}
</div>
{(info.description || info.chapters.length > 0) && (
<aside className="watch-details">
{info.description && <p>{info.description}</p>}
<aside className="space-y-6">
{info.description && (
<div className="rounded-lg border border-border bg-card p-4">
<Markdown content={info.description} />
</div>
)}
{info.chapters.length > 0 && (
<section>
<h2>节目章节</h2>
<div className="chapter-list">
<section className="rounded-lg border border-border bg-card p-4">
<h2 className="mb-3 font-semibold">节目章节</h2>
<div className="space-y-2">
{info.chapters.map((chapter, index) => (
<button className="viewer-button chapter-button" key={`${chapter.time}-${index}`} type="button" onClick={() => jumpToChapter(chapter.time)}>
<button
key={`${chapter.time}-${index}`}
type="button"
onClick={() => jumpToChapter(chapter.time)}
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent"
>
<span>{chapter.title}</span>
<time>{Math.floor(chapter.time / 60)}:{String(chapter.time % 60).padStart(2, '0')}</time>
<time className="tabular-nums text-muted-foreground">
{(() => { const t = Math.round(chapter.time); return `${String(Math.floor(t / 3600)).padStart(2, '0')}:${String(Math.floor((t % 3600) / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`; })()}
</time>
</button>
))}
</div>
-579
View File
@@ -1,579 +0,0 @@
.viewer-shell {
min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
background: #f4f0e8;
color: #211d18;
font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif;
}
.viewer-button {
appearance: none;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
font: 600 13px/1 Inter, "Noto Sans SC", sans-serif;
transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, color 160ms ease, transform 160ms ease;
}
.viewer-button:hover { transform: translateY(-1px); }
.viewer-button:active { transform: translateY(0); }
.viewer-button:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(118, 95, 69, 0.2); }
.viewer-button:disabled { cursor: not-allowed; opacity: 0.48; transform: none; }
.viewer-button--ghost { padding: 9px 14px; border-color: #d2cabf; background: rgba(255, 255, 255, 0.48); color: #403a33; }
.viewer-button--ghost:hover { border-color: #aa9f91; background: #fffdf9; color: #211d18; }
.viewer-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 72px;
color: #6f695f;
font-family: Inter, "Noto Sans SC", sans-serif;
font-size: 13px;
}
.viewer-nav span { color: #292720; font-weight: 650; }
.viewer-nav a { color: inherit; text-decoration: none; }
.viewer-nav a:hover { color: #292720; }
.viewer-directory-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 30px;
}
.viewer-search {
display: flex;
align-items: center;
width: min(420px, 100%);
gap: 10px;
padding: 11px 13px;
border: 1px solid #d8d2c8;
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
color: #767066;
box-shadow: 0 1px 2px rgba(33, 29, 24, 0.04), 0 8px 24px rgba(33, 29, 24, 0.04);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.viewer-search:focus-within { border-color: #aaa296; box-shadow: 0 0 0 3px rgba(117, 108, 94, 0.08); }
.viewer-search input { appearance: none; width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: #292720; font: 14px/1.4 Inter, "Noto Sans SC", sans-serif; }
.viewer-search input::placeholder { color: #9d978d; }
.viewer-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.viewer-filter {
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #777168;
cursor: pointer;
font: 13px/1 Inter, "Noto Sans SC", sans-serif;
}
.viewer-filter:hover { border-color: #d8d2c8; color: #292720; background: rgba(255, 255, 255, 0.72); }
.viewer-filter.is-active { border-color: #292720; background: #292720; color: #fffdf9; box-shadow: 0 5px 14px rgba(33, 29, 24, 0.16); }
.stream-card { border-color: #d9d3c9; box-shadow: none; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; }
.stream-card:hover { transform: translateY(-2px); border-color: #bcb4a8; }
.stream-copy h3 { font-size: clamp(17px, 1.6vw, 21px); }
.viewer-hero {
max-width: 1180px;
margin: 0 auto;
padding: 88px 40px 72px;
border-bottom: 1px solid #d7cec0;
}
.viewer-eyebrow {
margin-bottom: 18px;
color: #806d56;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.22em;
}
.viewer-logo {
display: block;
max-width: 180px;
height: 36px;
margin-bottom: 24px;
object-fit: contain;
object-position: left center;
}
.viewer-title {
max-width: 850px;
margin: 0;
color: #211d18;
font-size: clamp(42px, 7vw, 76px);
font-weight: 600;
line-height: 1.08;
letter-spacing: -0.035em;
}
.viewer-tagline {
max-width: 680px;
margin: 22px 0 0;
color: #685f54;
font-size: clamp(16px, 2vw, 20px);
line-height: 1.75;
}
.viewer-main {
max-width: 1180px;
margin: 0 auto;
padding: 52px 40px 96px;
}
.viewer-section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
margin-bottom: 32px;
}
.viewer-section-heading h2 {
margin: 0;
font-size: 25px;
font-weight: 600;
letter-spacing: -0.02em;
}
.viewer-section-heading span {
color: #84796c;
font-size: 13px;
}
.stream-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 32px 26px;
}
.stream-card {
appearance: none;
display: block;
width: 100%;
overflow: hidden;
border: 1px solid #dcd3c7;
border-radius: 14px;
background: #fffdf9;
color: inherit;
font: inherit;
text-align: left;
}
button.stream-card {
padding: 0;
cursor: pointer;
transition: border-color 140ms ease, transform 140ms ease;
}
button.stream-card:hover {
border-color: #a99882;
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(40, 34, 27, 0.09);
}
button.stream-card:active {
transform: translateY(0);
}
button.stream-card:focus-visible {
outline: 2px solid #765f45;
outline-offset: 3px;
}
.stream-cover {
position: relative;
display: flex;
aspect-ratio: 16 / 9;
align-items: center;
justify-content: center;
background-color: #29251f;
background-position: center;
background-size: cover;
}
.stream-status {
position: absolute;
top: 14px;
left: 14px;
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
font-family: system-ui, sans-serif;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.06em;
}
.viewer-empty {
display: grid;
min-height: 300px;
place-items: center;
align-content: center;
padding: 56px 24px;
border: 1px dashed #cec5b8;
border-radius: 16px;
background: rgba(255, 253, 249, 0.72);
text-align: center;
}
.viewer-empty-icon { display: grid; width: 58px; height: 58px; margin-bottom: 18px; place-items: center; border-radius: 18px; background: #ebe4da; color: #766d62; font-size: 25px; }
.viewer-empty h3 { color: #3f3932; font-size: 19px; font-weight: 650; }
.viewer-empty p { margin-top: 6px; color: #84796c; font: 14px/1.6 Inter, "Noto Sans SC", sans-serif; }
.stream-status.live {
background: #8f302b;
color: #fff;
}
.stream-status.pending {
background: #27231e;
color: #f8f3ea;
}
.stream-status.ended {
top: auto;
bottom: 14px;
background: rgba(30, 27, 23, 0.82);
color: #e7dfd3;
}
.live-dot {
width: 6px;
height: 6px;
margin-right: 7px;
border-radius: 50%;
background: currentColor;
}
.stream-copy {
min-height: 112px;
padding: 20px 22px 22px;
}
.stream-copy h3 {
overflow: hidden;
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.stream-copy p {
overflow: hidden;
margin: 0;
color: #766d62;
font-size: 14px;
line-height: 1.6;
text-overflow: ellipsis;
white-space: nowrap;
}
.stream-skeleton {
pointer-events: none;
}
.viewer-footer {
padding: 26px 40px;
border-top: 1px solid #d7cec0;
color: #84796c;
font-size: 12px;
text-align: center;
}
.watch-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #171512;
color: #eee7dc;
font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif;
overflow-x: hidden;
}
.watch-header {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 10px 28px;
border-bottom: 1px solid #342f29;
background: #1d1a16;
}
.watch-heading,
.watch-options {
display: flex;
align-items: center;
gap: 16px;
}
.watch-divider {
width: 1px;
height: 18px;
background: rgba(235, 229, 219, 0.1);
}
.watch-title {
color: #ebe5db;
font-size: 14px;
font-weight: 500;
}
.watch-quality { width: 100px; }
.watch-line { width: 150px; }
.watch-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 36px;
}
.watch-content {
width: 100%;
max-width: 1280px;
}
.watch-frame {
position: relative;
width: 100%;
max-width: 1280px;
max-height: calc(100vh - 136px);
overflow: hidden;
aspect-ratio: 16 / 9;
border: 1px solid #3d3730;
border-radius: 8px;
background: #080706;
}
.watch-player {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
}
.playback-stats {
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
width: min(360px, calc(100% - 32px));
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
background: rgba(18, 16, 13, 0.94);
color: #eee7dc;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
}
.playback-stats div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.playback-stats span { color: #a99f92; }
.playback-stats strong { min-width: 0; overflow-wrap: anywhere; font-weight: 500; }
.playback-stats-url { border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 6px; padding-top: 10px !important; }
.watch-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.watch-waiting {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(10, 10, 8, 0.92);
}
.watch-waiting .ant-typography { color: #949088; font-size: 17px; }
.watch-details {
margin-top: 24px;
padding: 24px;
border: 1px solid #3d3730;
border-radius: 8px;
background: #1d1a16;
}
.watch-details p { margin: 0 0 20px; color: #b7aea2; line-height: 1.7; }
.watch-details h2 { margin: 0 0 12px; font-size: 16px; }
.chapter-list { display: grid; gap: 8px; }
.chapter-list button {
display: flex;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: 1px solid #3d3730;
border-radius: 6px;
background: #24201b;
color: #eee7dc;
cursor: pointer;
text-align: left;
}
.chapter-button { width: 100%; border-radius: 8px; }
.chapter-list button:hover { border-color: #9d8263; background: #302920; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16); }
.chapter-list button:focus-visible { border-color: #b99a76; box-shadow: 0 0 0 3px rgba(185, 154, 118, 0.22); }
.chapter-list time { color: #949088; font-variant-numeric: tabular-nums; }
@media (max-width: 720px) {
.watch-header {
align-items: flex-start;
flex-direction: column;
padding: 14px 16px;
}
.watch-heading,
.watch-options {
width: 100%;
flex-wrap: wrap;
gap: 10px;
}
.watch-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.watch-options .ant-tag,
.watch-options .ant-typography { display: none; }
.watch-quality,
.watch-line { flex: 1; width: auto; min-width: 120px; }
.watch-stage {
align-items: flex-start;
padding: 20px 0;
}
.watch-frame {
border-right: 0;
border-left: 0;
border-radius: 0;
}
.watch-details {
margin: 16px;
padding: 18px;
}
}
@media (max-width: 900px) {
.stream-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.watch-shell,
.watch-stage,
.watch-content,
.watch-frame,
.watch-video {
width: 100%;
max-width: 100vw;
min-width: 0;
}
.watch-header {
box-sizing: border-box;
overflow: hidden;
}
.watch-options {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.watch-quality,
.watch-line {
width: 100% !important;
min-width: 0 !important;
}
.viewer-nav { margin-bottom: 48px; }
.viewer-directory-tools { align-items: stretch; flex-direction: column; }
.viewer-search { width: auto; }
.viewer-filters { max-width: 100%; overflow-x: auto; flex-wrap: nowrap; padding-bottom: 6px; scrollbar-width: none; }
.viewer-filters::-webkit-scrollbar { display: none; }
.viewer-filters button { flex: none; }
.viewer-hero {
padding: 58px 20px 48px;
}
.viewer-main {
padding: 38px 20px 64px;
}
.viewer-title {
font-size: clamp(38px, 13vw, 54px);
}
.stream-grid {
grid-template-columns: 1fr;
gap: 24px;
}
.viewer-section-heading {
margin-bottom: 24px;
}
.viewer-footer {
padding-inline: 20px;
}
}
@media (max-width: 360px) {
.viewer-hero, .viewer-main { padding-inline: 16px; }
.viewer-nav { gap: 12px; }
.viewer-nav span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.watch-header { padding-inline: 12px; }
.watch-quality, .watch-line { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.viewer-shell *,
.viewer-shell *::before,
.viewer-shell *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+74
View File
@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
}
+4 -1
View File
@@ -15,7 +15,10 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+32 -4
View File
@@ -1,17 +1,45 @@
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// Local dev proxy target. Defaults to the production backend port (8080).
// Override with BACKEND_TARGET when the loopback 8080 is occupied (e.g. by
// an unrelated process) — e.g. BACKEND_TARGET=http://127.0.0.1:8081.
const backendTarget = process.env.BACKEND_TARGET || 'http://127.0.0.1:8080'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
allowedHosts: ['nbd.rainplay.cn', '.rainplay.cn'],
allowedHosts: ['nbd.rainplay.cn', '.rainplay.cn', '.server2.y-dev.tech', 'live.server2.y-dev.tech', 'us.dl.y-dev.tech'],
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
target: backendTarget,
changeOrigin: true,
},
'/hls': {
target: 'http://127.0.0.1:8080',
target: backendTarget,
changeOrigin: true,
// Default "same-origin" strips the Referer. /hls/<key>/<label>/… carries the
// quality label, which is Chinese by default ("原画"), and a stripped Referer
// makes some proxies drop the rate-limited request. Not needed for the other
// routes, which only carry numeric ids.
referrerPolicy: 'origin-when-cross-origin',
},
'/docs': {
target: backendTarget,
changeOrigin: true,
},
'/openapi.json': {
target: backendTarget,
changeOrigin: true,
},
'/redoc': {
target: backendTarget,
changeOrigin: true,
},
},
+65 -10
View File
@@ -1,15 +1,70 @@
param(
[switch]$InstallDeps,
[switch]$BuildFrontend,
[string]$HostAddress = "0.0.0.0",
[int]$BackendPort = 8080
)
$ErrorActionPreference = "Stop"
$Base = Split-Path -Parent $MyInvocation.MyCommand.Path
$BackendDir = Join-Path $Base "backend"
$RuntimeDir = Join-Path $Base "runtime"
$HlsDir = Join-Path $RuntimeDir "hls"
$ReplayDir = Join-Path $RuntimeDir "replay"
$LogDir = Join-Path $RuntimeDir "logs"
Write-Host "[StreamCore] Starting backend..."
$Python = Join-Path $Base "backend\.venv\Scripts\python.exe"
if (-not (Test-Path $Python)) {
$Python = Join-Path $Base "backend\venv\Scripts\python.exe"
}
if (-not (Test-Path $Python)) {
throw "Python virtual environment not found. Create backend\.venv and install backend\requirements.txt."
New-Item -ItemType Directory -Force -Path $RuntimeDir, $HlsDir, $ReplayDir, $LogDir | Out-Null
if (-not $env:LS_HLS_RUNTIME_DIR) { $env:LS_HLS_RUNTIME_DIR = $HlsDir }
if (-not $env:LS_REPLAY_MEDIA_DIR) { $env:LS_REPLAY_MEDIA_DIR = $ReplayDir }
function Resolve-Python {
$candidates = @(
(Join-Path $BackendDir ".venv\Scripts\python.exe"),
(Join-Path $BackendDir "venv\Scripts\python.exe"),
"python",
"py"
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) { return $candidate }
$cmd = Get-Command $candidate -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
}
throw "Python not found. Install Python 3.11+ or create backend\.venv."
}
Start-Process -FilePath $Python -ArgumentList "run.py" -WorkingDirectory (Join-Path $Base "backend")
Write-Host "[StreamCore] Backend started on http://127.0.0.1:8080"
Write-Host "Run npm install and npm run dev in frontend for the development UI."
$Python = Resolve-Python
if ($InstallDeps) {
Write-Host "[StreamCore] Installing backend dependencies..."
Push-Location $BackendDir
& $Python -m pip install -r requirements.txt
Pop-Location
}
# The frontend is built once and then served by the backend, so Node is only
# needed here — production never runs npm. Done automatically on a first start so
# a fresh checkout does not come up as a 503 with no obvious cause.
$FrontendDir = Join-Path $Base "frontend"
$DistDir = Join-Path $FrontendDir "dist"
if ($BuildFrontend -or -not (Test-Path (Join-Path $DistDir "index.html"))) {
if (Get-Command "npm" -ErrorAction SilentlyContinue) {
Write-Host "[StreamCore] Building frontend..."
Push-Location $FrontendDir
if (-not (Test-Path "node_modules")) { npm install }
npm run build
Pop-Location
} else {
Write-Warning "npm not found and frontend\dist is missing; the site will return 503."
Write-Warning "Install Node.js and run: cd frontend; npm install; npm run build"
}
}
# MediaMTX, the transcoders and the HLS tree are managed by the backend itself:
# it starts the media origin at boot, supervises ffmpeg, and restarts either if it
# dies. This launcher only has to bring up the process that owns all of that.
Write-Host "[StreamCore] Starting backend on ${HostAddress}:${BackendPort} ..."
Write-Host "[StreamCore] Frontend, MediaMTX, transcoders and HLS are served by the backend."
$backendArgs = @("-m", "uvicorn", "main:app", "--host", $HostAddress, "--port", "$BackendPort")
& $Python @backendArgs
Executable → Regular
+29 -34
View File
@@ -1,44 +1,39 @@
#!/bin/bash
# StreamCore - Start all services
# StreamCore - Start the backend.
#
# The backend owns everything: it serves the built frontend, starts the media
# origin at boot, supervises ffmpeg, and restarts either if it dies. This script
# only has to bring up the process that owns all of that. Node is a build-time
# dependency only (see the frontend build below), never a runtime one.
set -e
BASE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
echo "[StreamCore] Starting services..."
export LS_HLS_RUNTIME_DIR="${LS_HLS_RUNTIME_DIR:-$BASE/runtime/hls}"
export LS_REPLAY_MEDIA_DIR="${LS_REPLAY_MEDIA_DIR:-$BASE/runtime/replay}"
mkdir -p "$LS_HLS_RUNTIME_DIR" "$LS_REPLAY_MEDIA_DIR" "$BASE/runtime/logs"
# MediaMTX origin
cd "$BASE/mediamtx"
if pgrep -f "mediamtx mediamtx.yml" > /dev/null 2>/dev/null; then
echo "[StreamCore] MediaMTX already running."
else
nohup ./mediamtx mediamtx.yml > /tmp/mediamtx.log 2>&1 &
sleep 2
echo "[StreamCore] MediaMTX started (RTMP :1935, HLS :8888, API :9997)."
HOST="${LS_HOST:-0.0.0.0}"
PORT="${LS_PORT:-8080}"
# Build the frontend once, so a fresh checkout does not come up as a 503.
if [ ! -f "$BASE/frontend/dist/index.html" ]; then
if command -v npm >/dev/null 2>&1; then
echo "[StreamCore] Building frontend..."
cd "$BASE/frontend"
[ -d node_modules ] || npm install
npm run build
else
echo "[StreamCore] WARNING: npm not found and frontend/dist is missing; the site will return 503." >&2
echo "[StreamCore] WARNING: install Node.js and run: cd frontend && npm install && npm run build" >&2
fi
fi
# Backend API
cd "$BASE/backend"
if curl -sf http://127.0.0.1:8080/api/health > /dev/null 2>&1; then
echo "[StreamCore] Backend already running."
else
(exec ./venv/bin/python run.py &) </dev/null &>/tmp/backend.log &
disown
sleep 3
echo "[StreamCore] Backend started on :8080."
fi
PY="$BASE/backend/venv/bin/python"
[ -x "$PY" ] || PY="$BASE/backend/.venv/bin/python"
[ -x "$PY" ] || PY="python"
# Frontend
cd "$BASE/frontend"
if curl -sf http://127.0.0.1:5173 > /dev/null 2>&1; then
echo "[StreamCore] Frontend already running."
else
(exec npm run dev &) </dev/null &>/tmp/frontend.log &
disown
sleep 3
echo "[StreamCore] Frontend started on :5173."
fi
echo "[StreamCore] All services up."
echo " Public: http://nbd.rainplay.cn:13001/"
echo " Admin: http://nbd.rainplay.cn:13001/admin/login"
echo " RTMP: rtmp://nbd.rainplay.cn:13001/live/{key}"
echo "[StreamCore] Starting backend on ${HOST}:${PORT}..."
echo "[StreamCore] Frontend, MediaMTX, transcoders and HLS are served by the backend."
exec "$PY" -m uvicorn main:app --host "$HOST" --port "$PORT"