Compare commits
10
Commits
655783b724
...
8e61dc111c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e61dc111c | ||
|
|
cdf591f933 | ||
|
|
4ee945ca0d | ||
|
|
996e3fea39 | ||
|
|
186e32caf1 | ||
|
|
57b00affbd | ||
|
|
da01967c2e | ||
|
|
7353d140b8 | ||
|
|
1e87be31da | ||
|
|
38824a0055 |
No files matched your search
+25
-2
@@ -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
@@ -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
@@ -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_"}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
File mode changed.
Executable → Regular
File mode changed.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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} © {new Date().getFullYear()}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')],
|
||||
}
|
||||
@@ -15,7 +15,10 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+32
-4
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
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 }
|
||||
}
|
||||
if (-not (Test-Path $Python)) {
|
||||
throw "Python virtual environment not found. Create backend\.venv and install backend\requirements.txt."
|
||||
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
|
||||
@@ -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)."
|
||||
fi
|
||||
HOST="${LS_HOST:-0.0.0.0}"
|
||||
PORT="${LS_PORT:-8080}"
|
||||
|
||||
# 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
|
||||
|
||||
# Frontend
|
||||
# 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"
|
||||
if curl -sf http://127.0.0.1:5173 > /dev/null 2>&1; then
|
||||
echo "[StreamCore] Frontend already running."
|
||||
[ -d node_modules ] || npm install
|
||||
npm run build
|
||||
else
|
||||
(exec npm run dev &) </dev/null &>/tmp/frontend.log &
|
||||
disown
|
||||
sleep 3
|
||||
echo "[StreamCore] Frontend started on :5173."
|
||||
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
|
||||
|
||||
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}"
|
||||
cd "$BASE/backend"
|
||||
PY="$BASE/backend/venv/bin/python"
|
||||
[ -x "$PY" ] || PY="$BASE/backend/.venv/bin/python"
|
||||
[ -x "$PY" ] || PY="python"
|
||||
|
||||
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"
|
||||
Reference in new issue
Block a user