feat: keyboard seek, playback speed, and persist edited settings

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-29 12:44:33 +08:00
1 parent cdf591f933
commit 8e61dc111c
7 files changed
+232 -29

No files matched your search

+4
View File
@@ -21,6 +21,7 @@ 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 _origin_paths() -> dict[str, dict]:
@@ -150,6 +151,9 @@ async def monitor_origin():
@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
+4
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from config import settings
from services.settings_store import save_settings
from routes.auth import require_auth
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
@@ -83,6 +84,9 @@ async def update_strategy(body: StrategyUpdate, _: bool = Depends(require_auth))
settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled
# Persisted for the same reason as the brand: in-memory only meant a restart
# silently restored the defaults.
save_settings()
return {"ok": True}
+5
View File
@@ -8,6 +8,7 @@ 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"])
@@ -46,6 +47,9 @@ 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}
@@ -88,6 +92,7 @@ async def update_transcode_profiles(
settings.transcode_profiles = normalized
settings.qualities = next(iter(normalized.values()))
save_settings()
return {"ok": True, "profiles": normalized}
+55
View File
@@ -0,0 +1,55 @@
"""Persist the settings an administrator edits in the UI.
config.py holds the defaults, but anything changed at runtime has to survive a
restart — otherwise editing the brand or the transcode ladder appears to work and
is silently reverted the next time the backend starts. Writes are atomic (temp
file + replace) so a crash mid-save cannot leave a half-written file that fails
to parse on boot.
"""
import json
from pathlib import Path
from config import settings
STORE_PATH = Path(__file__).resolve().parent.parent / "settings.json"
# Settings the UI may change, with the attribute they map to on Settings.
_MANAGED = (
"brand_name",
"brand_tagline",
"brand_logo_url",
"load_balance_strategy",
"multicast_enabled",
"transcode_profiles",
"qualities",
)
def load_settings() -> None:
"""Apply saved overrides on top of the config defaults. Never raises."""
if not STORE_PATH.exists():
return
try:
saved = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
# A corrupt file must not stop the server from booting; the defaults from
# config.py are still usable.
return
if not isinstance(saved, dict):
return
for key, value in saved.items():
# Only known keys, so a stale or hand-edited file cannot inject arbitrary
# attributes onto the settings object.
if key in _MANAGED and value is not None:
setattr(settings, key, value)
def save_settings() -> None:
payload = {key: getattr(settings, key) for key in _MANAGED}
temporary = STORE_PATH.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
temporary.replace(STORE_PATH)