Tuner v1: HLS pipeline with playback/ingest modes
FFmpeg -> MediaMTX -> HLS relay with runtime-configurable modes: - PLAYBACK_MODE: standard (mpegts), fmp4 (HEVC), llhls - INGEST_MODE: rtmp, rtsp - INGEST_AUDIO_MODE: aac, copy - Hourly playlist auto-refresh from upstream URL - Fullscreen video UI with collapsible sidebar, upstream-down banner - Same-origin HLS proxy for HTTPS deployments Tagged as the last HLS-based version before the mpegts.js (v2) rework.
This commit is contained in:
@@ -26,6 +26,15 @@ COPY mediamtx.yml /app/mediamtx.yml
|
||||
|
||||
ENV RESTREAMER_PORT=8080
|
||||
ENV PLAYLIST_PATH=/data/playlist.m3u
|
||||
ENV PLAYLIST_URL=https://pia.cx/m3u/630xhAmY/smp424GL
|
||||
ENV PLAYLIST_USE_LOCAL=false
|
||||
ENV PLAYLIST_AUTO_REFRESH_INTERVAL=1h
|
||||
# Playback mode: "standard" (MPEG-TS, iOS-friendly), "fmp4" (HEVC/H.265), or "llhls"
|
||||
ENV PLAYBACK_MODE=standard
|
||||
# Ingest mode: "rtmp" (default) or "rtsp" (better for HEVC/H.265)
|
||||
ENV INGEST_MODE=rtmp
|
||||
# RTSP ingest audio mode: "aac" (transcode audio, recommended) or "copy"
|
||||
ENV INGEST_AUDIO_MODE=aac
|
||||
ENV MEDIAMTX_PATH=/usr/local/bin/mediamtx
|
||||
ENV MEDIAMTX_CONFIG=/app/mediamtx.yml
|
||||
ENV FRONTEND_DIR=/app/frontend/dist
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tuner/m3u"
|
||||
"tuner/models"
|
||||
@@ -13,12 +14,34 @@ import (
|
||||
|
||||
// App holds shared application state for all handlers.
|
||||
type App struct {
|
||||
mu sync.RWMutex
|
||||
Channels []models.Channel
|
||||
Status models.StreamStatus
|
||||
PlaylistPath string
|
||||
MediaMTX *process.MediaMTXManager
|
||||
FFmpeg *process.FFmpegManager
|
||||
mu sync.RWMutex
|
||||
Channels []models.Channel
|
||||
Status models.StreamStatus
|
||||
PlaylistPath string
|
||||
PlaylistURL string // if set, reload fetches from this URL before parsing
|
||||
PlaybackMode string // "llhls" or "standard"
|
||||
SwitchStartedAt time.Time
|
||||
CurrentStreamURL string
|
||||
PlaylistVersion int64
|
||||
MediaMTX *process.MediaMTXManager
|
||||
FFmpeg *process.FFmpegManager
|
||||
}
|
||||
|
||||
// HandleConfig returns runtime client configuration as JSON.
|
||||
// GET /api/config
|
||||
func (a *App) HandleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
mode := a.PlaybackMode
|
||||
if mode == "" {
|
||||
mode = "standard"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"playback_mode": mode})
|
||||
}
|
||||
|
||||
// HandleChannels lists, searches, and filters channels.
|
||||
@@ -89,6 +112,9 @@ func (a *App) HandleSetSource(w http.ResponseWriter, r *http.Request) {
|
||||
a.mu.Lock()
|
||||
a.Status.ChannelName = ""
|
||||
a.Status.Transitioning = false
|
||||
a.Status.UpstreamDown = false
|
||||
a.SwitchStartedAt = time.Time{}
|
||||
a.CurrentStreamURL = ""
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -141,6 +167,9 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
a.Status.ChannelName = found.Name
|
||||
a.Status.Live = true
|
||||
a.Status.Transitioning = true
|
||||
a.Status.UpstreamDown = false
|
||||
a.SwitchStartedAt = time.Now()
|
||||
a.CurrentStreamURL = found.StreamURL
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[admin] channel set to %s (%s)", found.Name, found.ID)
|
||||
@@ -149,7 +178,42 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "channel": found.Name})
|
||||
}
|
||||
|
||||
// HandleReloadPlaylist re-parses the M3U playlist from disk.
|
||||
// ReloadPlaylist refreshes the playlist from its configured source, reparses it,
|
||||
// increments the playlist version, and remaps the active channel name when the
|
||||
// current stream URL still exists under a new display name.
|
||||
func (a *App) ReloadPlaylist() (int, error) {
|
||||
if a.PlaylistURL != "" {
|
||||
log.Printf("[playlist] re-fetching playlist from %s", a.PlaylistURL)
|
||||
if err := FetchPlaylist(a.PlaylistURL, a.PlaylistPath); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
channels, err := m3u.ParseFile(a.PlaylistPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.Channels = channels
|
||||
a.PlaylistVersion++
|
||||
a.Status.PlaylistVersion = a.PlaylistVersion
|
||||
|
||||
if a.CurrentStreamURL != "" {
|
||||
for _, ch := range channels {
|
||||
if ch.StreamURL == a.CurrentStreamURL {
|
||||
a.Status.ChannelName = ch.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return len(channels), nil
|
||||
}
|
||||
|
||||
// HandleReloadPlaylist re-fetches (if a URL is configured) and re-parses the M3U playlist.
|
||||
// POST /api/admin/playlist/reload
|
||||
func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -157,20 +221,16 @@ func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := m3u.ParseFile(a.PlaylistPath)
|
||||
count, err := a.ReloadPlaylist()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to reload playlist: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.Channels = channels
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[admin] playlist reloaded: %d channels", len(channels))
|
||||
log.Printf("[admin] playlist reloaded: %d channels", count)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": len(channels)})
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": count})
|
||||
}
|
||||
|
||||
// HandleProcessStatus returns the status of managed processes.
|
||||
@@ -189,4 +249,3 @@ func (a *App) HandleProcessStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
|
||||
35
backend/api/playlist.go
Normal file
35
backend/api/playlist.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FetchPlaylist downloads a playlist from url and writes it to destPath.
|
||||
func FetchPlaylist(url, destPath string) error {
|
||||
log.Printf("[playlist] fetching from %s", url)
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch playlist: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetch playlist: server returned %s", resp.Status)
|
||||
}
|
||||
if err := os.MkdirAll("/data", 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir /data: %w", err)
|
||||
}
|
||||
f, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create playlist file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
return fmt.Errorf("write playlist file: %w", err)
|
||||
}
|
||||
log.Printf("[playlist] saved to %s", destPath)
|
||||
return nil
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const upstreamDownAfter = 10 * time.Second
|
||||
|
||||
// HandleStatus returns the current stream status as JSON.
|
||||
// GET /api/status
|
||||
func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -20,12 +23,26 @@ func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Attach live stream health metrics when FFmpeg is running
|
||||
status.Health = a.FFmpeg.Health()
|
||||
|
||||
// Auto-clear transitioning once FFmpeg is producing health data
|
||||
// Auto-clear transitioning once FFmpeg is producing health data.
|
||||
// If no progress arrives for a while after a channel switch, flag upstream_down
|
||||
// so all watchers see an "Upstream down?" warning via polling.
|
||||
if status.Transitioning && status.Health != nil && status.Health.Speed != "" {
|
||||
a.mu.Lock()
|
||||
a.Status.Transitioning = false
|
||||
a.Status.UpstreamDown = false
|
||||
a.mu.Unlock()
|
||||
status.Transitioning = false
|
||||
status.UpstreamDown = false
|
||||
} else if status.Transitioning {
|
||||
a.mu.RLock()
|
||||
switchStartedAt := a.SwitchStartedAt
|
||||
a.mu.RUnlock()
|
||||
if !switchStartedAt.IsZero() && time.Since(switchStartedAt) >= upstreamDownAfter {
|
||||
a.mu.Lock()
|
||||
a.Status.UpstreamDown = true
|
||||
a.mu.Unlock()
|
||||
status.UpstreamDown = true
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
158
backend/main.go
158
backend/main.go
@@ -2,10 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -21,22 +26,144 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// normalizePlaybackMode validates the PLAYBACK_MODE value. Accepts "standard",
|
||||
// "fmp4", or "llhls" (case-insensitive); anything else falls back to "standard".
|
||||
func normalizePlaybackMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "llhls", "lowlatency", "low-latency":
|
||||
return "llhls"
|
||||
case "fmp4", "mp4", "fragmented-mp4", "fragmented_mp4":
|
||||
return "fmp4"
|
||||
default:
|
||||
return "standard"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIngestMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "rtsp":
|
||||
return "rtsp"
|
||||
default:
|
||||
return "rtmp"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIngestAudioMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "copy":
|
||||
return "copy"
|
||||
default:
|
||||
return "aac"
|
||||
}
|
||||
}
|
||||
|
||||
// mediamtxEnvForMode returns the MTX_* environment variable overrides for the
|
||||
// given playback mode.
|
||||
func mediamtxEnvForMode(mode string) []string {
|
||||
if mode == "llhls" {
|
||||
// Low-Latency HLS: minimal latency, more sensitive to upstream jitter.
|
||||
return []string{
|
||||
"MTX_HLSVARIANT=lowLatency",
|
||||
"MTX_HLSSEGMENTCOUNT=7",
|
||||
"MTX_HLSSEGMENTDURATION=1s",
|
||||
"MTX_HLSPARTDURATION=200ms",
|
||||
}
|
||||
}
|
||||
if mode == "fmp4" {
|
||||
// Fragmented MP4 HLS: smooth standard HLS with HEVC/H.265 support.
|
||||
return []string{
|
||||
"MTX_HLSVARIANT=fmp4",
|
||||
"MTX_HLSSEGMENTCOUNT=7",
|
||||
"MTX_HLSSEGMENTDURATION=2s",
|
||||
}
|
||||
}
|
||||
// Standard HLS (mpegts): smooth, resilient, universally compatible (including iOS Safari).
|
||||
return []string{
|
||||
"MTX_HLSVARIANT=mpegts",
|
||||
"MTX_HLSSEGMENTCOUNT=7",
|
||||
"MTX_HLSSEGMENTDURATION=2s",
|
||||
}
|
||||
}
|
||||
|
||||
func parseRefreshInterval(value string) time.Duration {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
if v == "" || v == "0" || v == "off" || v == "false" || v == "disabled" {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Printf("[startup] warning: invalid PLAYLIST_AUTO_REFRESH_INTERVAL=%q, using 1h", value)
|
||||
return time.Hour
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// copyFile copies src to dst, creating dst if it doesn't exist.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll("/data", 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir /data: %w", err)
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dest: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
playlistPath := getEnv("PLAYLIST_PATH", "/data/playlist.m3u")
|
||||
mediamtxBin := getEnv("MEDIAMTX_PATH", "mediamtx")
|
||||
mediamtxCfg := getEnv("MEDIAMTX_CONFIG", "mediamtx.yml")
|
||||
port := getEnv("RESTREAMER_PORT", "8080")
|
||||
playlistURL := getEnv("PLAYLIST_URL", "")
|
||||
useLocal := getEnv("PLAYLIST_USE_LOCAL", "false") == "true"
|
||||
playbackMode := normalizePlaybackMode(getEnv("PLAYBACK_MODE", "standard"))
|
||||
ingestMode := normalizeIngestMode(getEnv("INGEST_MODE", "rtmp"))
|
||||
ingestAudioMode := normalizeIngestAudioMode(getEnv("INGEST_AUDIO_MODE", "aac"))
|
||||
playlistRefreshInterval := parseRefreshInterval(getEnv("PLAYLIST_AUTO_REFRESH_INTERVAL", "1h"))
|
||||
|
||||
localSrcPath := getEnv("PLAYLIST_LOCAL_SRC", "/data/playlist.m3u.local")
|
||||
|
||||
// Fetch playlist from URL unless PLAYLIST_USE_LOCAL=true
|
||||
if !useLocal && playlistURL != "" {
|
||||
if err := api.FetchPlaylist(playlistURL, playlistPath); err != nil {
|
||||
log.Printf("[startup] warning: could not fetch playlist from URL: %v", err)
|
||||
}
|
||||
} else if useLocal {
|
||||
log.Printf("[startup] PLAYLIST_USE_LOCAL=true, copying %s -> %s", localSrcPath, playlistPath)
|
||||
if err := copyFile(localSrcPath, playlistPath); err != nil {
|
||||
log.Printf("[startup] warning: could not copy local playlist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize process managers
|
||||
mtxManager := process.NewMediaMTXManager(mediamtxBin, mediamtxCfg)
|
||||
ffmpegManager := process.NewFFmpegManager()
|
||||
mtxManager.SetEnv(mediamtxEnvForMode(playbackMode))
|
||||
ffmpegManager := process.NewFFmpegManager(ingestMode, ingestAudioMode)
|
||||
log.Printf("[startup] playback mode: %s", playbackMode)
|
||||
log.Printf("[startup] ingest mode: %s", ingestMode)
|
||||
log.Printf("[startup] ingest audio mode: %s", ingestAudioMode)
|
||||
|
||||
// Build shared app state
|
||||
app := &api.App{
|
||||
PlaylistPath: playlistPath,
|
||||
PlaybackMode: playbackMode,
|
||||
MediaMTX: mtxManager,
|
||||
FFmpeg: ffmpegManager,
|
||||
}
|
||||
// Only expose the URL for reloads when we're in URL mode
|
||||
if !useLocal && playlistURL != "" {
|
||||
app.PlaylistURL = playlistURL
|
||||
}
|
||||
|
||||
// Load playlist (warn but don't crash if missing)
|
||||
channels, err := m3u.ParseFile(playlistPath)
|
||||
@@ -44,9 +171,29 @@ func main() {
|
||||
log.Printf("[startup] warning: could not load playlist %s: %v", playlistPath, err)
|
||||
} else {
|
||||
app.Channels = channels
|
||||
app.PlaylistVersion = 1
|
||||
app.Status.PlaylistVersion = app.PlaylistVersion
|
||||
log.Printf("[startup] loaded %d channels from %s", len(channels), playlistPath)
|
||||
}
|
||||
|
||||
if playlistRefreshInterval > 0 {
|
||||
log.Printf("[startup] playlist auto-refresh enabled: %s", playlistRefreshInterval)
|
||||
go func() {
|
||||
ticker := time.NewTicker(playlistRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
count, err := app.ReloadPlaylist()
|
||||
if err != nil {
|
||||
log.Printf("[playlist] auto-refresh failed: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[playlist] auto-refreshed: %d channels", count)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Printf("[startup] playlist auto-refresh disabled")
|
||||
}
|
||||
|
||||
// Start MediaMTX (warn but don't crash if binary not found)
|
||||
if err := mtxManager.Start(); err != nil {
|
||||
log.Printf("[startup] warning: could not start mediamtx: %v", err)
|
||||
@@ -56,6 +203,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/config", app.HandleConfig)
|
||||
mux.HandleFunc("/api/status", app.HandleStatus)
|
||||
mux.HandleFunc("/api/admin/channels", app.HandleChannels)
|
||||
mux.HandleFunc("/api/admin/groups", app.HandleGroups)
|
||||
@@ -64,6 +212,14 @@ func main() {
|
||||
mux.HandleFunc("/api/admin/playlist/reload", app.HandleReloadPlaylist)
|
||||
mux.HandleFunc("/api/admin/process/status", app.HandleProcessStatus)
|
||||
|
||||
// Proxy HLS through the main app origin so HTTPS deployments avoid mixed content.
|
||||
hlsURL, err := url.Parse("http://127.0.0.1:8888")
|
||||
if err != nil {
|
||||
log.Printf("[startup] warning: invalid HLS proxy URL: %v", err)
|
||||
} else {
|
||||
mux.Handle("/live/", httputil.NewSingleHostReverseProxy(hlsURL))
|
||||
}
|
||||
|
||||
// Serve frontend static files (falls back to index.html for SPA routing)
|
||||
frontendDir := getEnv("FRONTEND_DIR", "frontend/dist")
|
||||
if info, err := os.Stat(frontendDir); err == nil && info.IsDir() {
|
||||
|
||||
@@ -22,11 +22,13 @@ type StreamHealth struct {
|
||||
|
||||
// StreamStatus represents the current streaming state.
|
||||
type StreamStatus struct {
|
||||
Source string `json:"source"` // "obs" or "iptv"
|
||||
ChannelName string `json:"channel_name"` // current IPTV channel name (empty if OBS)
|
||||
Live bool `json:"live"`
|
||||
Transitioning bool `json:"transitioning"` // true while switching to a new channel
|
||||
Health *StreamHealth `json:"health,omitempty"`
|
||||
Source string `json:"source"` // "obs" or "iptv"
|
||||
ChannelName string `json:"channel_name"` // current IPTV channel name (empty if OBS)
|
||||
Live bool `json:"live"`
|
||||
Transitioning bool `json:"transitioning"` // true while switching to a new channel
|
||||
UpstreamDown bool `json:"upstream_down"` // true when no data is detected from the selected upstream
|
||||
PlaylistVersion int64 `json:"playlist_version"` // increments when playlist reloads
|
||||
Health *StreamHealth `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessInfo represents the status of a managed child process.
|
||||
|
||||
@@ -15,17 +15,25 @@ import (
|
||||
|
||||
// FFmpegManager manages an FFmpeg child process for IPTV relay.
|
||||
type FFmpegManager struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
startTime time.Time
|
||||
lastError string
|
||||
streamURL string
|
||||
health models.StreamHealth
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ingestMode string
|
||||
audioMode string
|
||||
startTime time.Time
|
||||
lastError string
|
||||
streamURL string
|
||||
health models.StreamHealth
|
||||
}
|
||||
|
||||
// NewFFmpegManager creates a new FFmpeg process manager.
|
||||
func NewFFmpegManager() *FFmpegManager {
|
||||
return &FFmpegManager{}
|
||||
func NewFFmpegManager(ingestMode, audioMode string) *FFmpegManager {
|
||||
if ingestMode != "rtsp" {
|
||||
ingestMode = "rtmp"
|
||||
}
|
||||
if audioMode != "copy" {
|
||||
audioMode = "aac"
|
||||
}
|
||||
return &FFmpegManager{ingestMode: ingestMode, audioMode: audioMode}
|
||||
}
|
||||
|
||||
// Start spawns an FFmpeg process to pull from streamURL and push to MediaMTX.
|
||||
@@ -37,17 +45,39 @@ func (f *FFmpegManager) Start(streamURL string) error {
|
||||
// Kill existing process if running
|
||||
f.stopLocked()
|
||||
|
||||
f.cmd = exec.Command("ffmpeg",
|
||||
args := []string{
|
||||
"-loglevel", "warning",
|
||||
"-progress", "pipe:1",
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-i", streamURL,
|
||||
"-c", "copy",
|
||||
"-f", "flv",
|
||||
"rtmp://localhost:1935/live/stream",
|
||||
)
|
||||
}
|
||||
if f.ingestMode == "rtsp" {
|
||||
if f.audioMode == "copy" {
|
||||
args = append(args, "-c", "copy")
|
||||
} else {
|
||||
args = append(args,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac",
|
||||
"-ar", "48000",
|
||||
"-b:a", "160k",
|
||||
)
|
||||
}
|
||||
args = append(args,
|
||||
"-f", "rtsp",
|
||||
"-rtsp_transport", "tcp",
|
||||
"rtsp://localhost:8554/live/stream",
|
||||
)
|
||||
} else {
|
||||
args = append(args,
|
||||
"-c", "copy",
|
||||
"-f", "flv",
|
||||
"rtmp://localhost:1935/live/stream",
|
||||
)
|
||||
}
|
||||
|
||||
f.cmd = exec.Command("ffmpeg", args...)
|
||||
f.streamURL = streamURL
|
||||
f.lastError = ""
|
||||
f.health = models.StreamHealth{}
|
||||
@@ -68,7 +98,7 @@ func (f *FFmpegManager) Start(streamURL string) error {
|
||||
}
|
||||
|
||||
f.startTime = time.Now()
|
||||
log.Printf("[ffmpeg] started with pid %d, source: %s", f.cmd.Process.Pid, streamURL)
|
||||
log.Printf("[ffmpeg] started with pid %d, ingest=%s, audio=%s, source: %s", f.cmd.Process.Pid, f.ingestMode, f.audioMode, streamURL)
|
||||
|
||||
// Pass cmd reference so goroutines can detect process replacement
|
||||
cmd := f.cmd
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -18,6 +19,7 @@ type MediaMTXManager struct {
|
||||
cmd *exec.Cmd
|
||||
binaryPath string
|
||||
configPath string
|
||||
extraEnv []string
|
||||
startTime time.Time
|
||||
lastError string
|
||||
stopCh chan struct{}
|
||||
@@ -32,6 +34,14 @@ func NewMediaMTXManager(binaryPath, configPath string) *MediaMTXManager {
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnv sets additional environment variables (e.g. MTX_HLSVARIANT) that are
|
||||
// passed to MediaMTX. Must be called before Start.
|
||||
func (m *MediaMTXManager) SetEnv(env []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.extraEnv = env
|
||||
}
|
||||
|
||||
// Start launches the MediaMTX process. It auto-restarts on unexpected exit.
|
||||
func (m *MediaMTXManager) Start() error {
|
||||
m.mu.Lock()
|
||||
@@ -52,6 +62,9 @@ func (m *MediaMTXManager) startLocked() error {
|
||||
}
|
||||
|
||||
m.cmd = exec.Command(m.binaryPath, m.configPath)
|
||||
if len(m.extraEnv) > 0 {
|
||||
m.cmd.Env = append(os.Environ(), m.extraEnv...)
|
||||
}
|
||||
m.lastError = ""
|
||||
m.stopped = false
|
||||
m.stopCh = make(chan struct{})
|
||||
|
||||
@@ -6,8 +6,22 @@ services:
|
||||
- "8080:8080"
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- ./real1.m3u:/data/playlist.m3u:ro
|
||||
# real1.m3u is mounted read-only as the local source.
|
||||
# It is only used when PLAYLIST_USE_LOCAL=true.
|
||||
- ./real1.m3u:/data/playlist.m3u.local:ro
|
||||
environment:
|
||||
RESTREAMER_PORT: "8080"
|
||||
PLAYLIST_PATH: "/data/playlist.m3u"
|
||||
# Default: fetch playlist from URL on every boot.
|
||||
# Set PLAYLIST_USE_LOCAL=true to use real1.m3u instead.
|
||||
PLAYLIST_URL: "https://pia.cx/m3u/630xhAmY/smp424GL"
|
||||
PLAYLIST_USE_LOCAL: "false"
|
||||
# Set to "0" or "off" to disable automatic playlist refresh.
|
||||
PLAYLIST_AUTO_REFRESH_INTERVAL: "1h"
|
||||
# Playback mode: "standard" (MPEG-TS, iOS-friendly), "fmp4" (HEVC/H.265), or "llhls"
|
||||
PLAYBACK_MODE: "standard"
|
||||
# Ingest mode: "rtmp" (default) or "rtsp" (better for HEVC/H.265)
|
||||
INGEST_MODE: "rtmp"
|
||||
# RTSP ingest audio mode: "aac" (transcode audio, recommended) or "copy"
|
||||
INGEST_AUDIO_MODE: "aac"
|
||||
restart: unless-stopped
|
||||
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## 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 ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -4,25 +4,44 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* App shell: video fills everything, UI floats on top */
|
||||
.app {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
/* height reserved for the fixed bottom status bar */
|
||||
--status-bar-height: 49px;
|
||||
}
|
||||
|
||||
/* Fullscreen video stage: sits above the bottom status bar so the
|
||||
native video controls (play button) are never hidden behind it */
|
||||
.stage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--status-bar-height);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Player */
|
||||
.player-container {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -30,39 +49,116 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.player-video:not([src]),
|
||||
.player-video[src=""] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-video:not([src]) ~ .player-offline,
|
||||
.player-video[src=""] ~ .player-offline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Offline overlay is controlled by React state, not CSS src tricks */
|
||||
.player-offline {
|
||||
display: none;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
font-size: 1.5rem;
|
||||
color: #555;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* StatusBar */
|
||||
/* Sidebar toggle button (far right, vertically centered) */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
z-index: 30;
|
||||
width: 32px;
|
||||
height: 64px;
|
||||
border: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: rgba(20, 20, 20, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #e0e0e0;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
/* hidden by default; shown on interaction */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: right 0.25s ease, background 0.15s, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(40, 40, 40, 0.85);
|
||||
}
|
||||
|
||||
.sidebar-toggle.open {
|
||||
right: 340px;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
z-index: 20;
|
||||
background: rgba(15, 15, 15, 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transition: transform 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
/* leave room for the bottom status bar */
|
||||
padding-bottom: 64px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* StatusBar (fixed to bottom) */
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 25;
|
||||
height: var(--status-bar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
margin-top: 8px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 0 16px;
|
||||
background: rgba(15, 15, 15, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.status-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -114,6 +210,19 @@ body {
|
||||
animation: switching-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.upstream-down-banner {
|
||||
color: #fecaca;
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
@keyframes channel-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
@@ -167,19 +276,13 @@ body {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Controls layout */
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* AdminPanel */
|
||||
.admin-panel {
|
||||
background: #1a1a1a;
|
||||
background: rgba(26, 26, 26, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
@@ -237,12 +340,14 @@ body {
|
||||
|
||||
/* ChannelList */
|
||||
.channel-list {
|
||||
background: #1a1a1a;
|
||||
background: rgba(26, 26, 26, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 500px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.channel-list-title {
|
||||
@@ -256,6 +361,7 @@ body {
|
||||
|
||||
.channel-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -287,7 +393,7 @@ body {
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
min-width: 140px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-items {
|
||||
@@ -356,8 +462,11 @@ body {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
.sidebar-toggle.open {
|
||||
right: calc(100% - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { StreamStatus, Channel } from "./types";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { StreamStatus, Channel, PlaybackMode } from "./types";
|
||||
import * as api from "./api/client";
|
||||
import Player from "./components/Player";
|
||||
import StatusBar from "./components/StatusBar";
|
||||
@@ -7,9 +7,32 @@ import AdminPanel from "./components/AdminPanel";
|
||||
import ChannelList from "./components/ChannelList";
|
||||
import "./App.css";
|
||||
|
||||
// Detect touch-primary devices (phones/tablets have no hover pointer).
|
||||
const isTouchPrimary = () =>
|
||||
window.matchMedia("(pointer: coarse)").matches || navigator.maxTouchPoints > 0;
|
||||
|
||||
const HIDE_DELAY = 3000; // ms of inactivity before toggle hides again
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<StreamStatus | null>(null);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [playbackMode, setPlaybackMode] = useState<PlaybackMode>("standard");
|
||||
const [toggleVisible, setToggleVisible] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getConfig()
|
||||
.then((c) => {
|
||||
if (c.playback_mode === "llhls" || c.playback_mode === "fmp4") {
|
||||
setPlaybackMode(c.playback_mode);
|
||||
} else {
|
||||
setPlaybackMode("standard");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -32,11 +55,53 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchChannels();
|
||||
// Poll faster while transitioning so the UI updates promptly
|
||||
const rate = status?.transitioning ? 1000 : 5000;
|
||||
// Poll faster while live so passive viewers detect channel switches promptly.
|
||||
// Transitioning gets the fastest rate so the UI clears quickly after FFmpeg starts.
|
||||
const rate = status?.transitioning ? 1000 : status?.live ? 2000 : 5000;
|
||||
const interval = setInterval(fetchStatus, rate);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus, fetchChannels, status?.transitioning]);
|
||||
}, [fetchStatus, fetchChannels, status?.live, status?.transitioning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.playlist_version) {
|
||||
fetchChannels();
|
||||
}
|
||||
}, [fetchChannels, status?.playlist_version]);
|
||||
|
||||
// Show the toggle button on interaction, then auto-hide after HIDE_DELAY.
|
||||
// When the sidebar is open we keep it visible so the user can close it.
|
||||
const showToggle = useCallback(() => {
|
||||
setToggleVisible(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (!sidebarOpen) {
|
||||
hideTimer.current = setTimeout(() => setToggleVisible(false), HIDE_DELAY);
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
// Keep toggle visible while sidebar is open; restart timer when it closes.
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
setToggleVisible(true);
|
||||
} else {
|
||||
hideTimer.current = setTimeout(() => setToggleVisible(false), HIDE_DELAY);
|
||||
}
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [sidebarOpen]);
|
||||
|
||||
// Wire up interaction listeners on the document.
|
||||
useEffect(() => {
|
||||
const touch = isTouchPrimary();
|
||||
if (touch) {
|
||||
document.addEventListener("touchstart", showToggle, { passive: true });
|
||||
return () => document.removeEventListener("touchstart", showToggle);
|
||||
} else {
|
||||
document.addEventListener("mousemove", showToggle);
|
||||
return () => document.removeEventListener("mousemove", showToggle);
|
||||
}
|
||||
}, [showToggle]);
|
||||
|
||||
const handleSourceChanged = () => {
|
||||
fetchStatus();
|
||||
@@ -52,20 +117,43 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Player live={status?.live ?? false} />
|
||||
<StatusBar status={status} />
|
||||
<div className="controls">
|
||||
<AdminPanel
|
||||
status={status}
|
||||
onSourceChanged={handleSourceChanged}
|
||||
onPlaylistReloaded={handlePlaylistReloaded}
|
||||
/>
|
||||
<ChannelList
|
||||
channels={channels}
|
||||
activeChannel={status?.channel_name ?? ""}
|
||||
onRefresh={handleChannelRefresh}
|
||||
{/* Fullscreen video background */}
|
||||
<div className="stage">
|
||||
<Player
|
||||
live={status?.live ?? false}
|
||||
mode={playbackMode}
|
||||
streamKey={`${status?.source ?? "offline"}:${status?.channel_name ?? ""}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapsible right sidebar */}
|
||||
<button
|
||||
className={`sidebar-toggle ${sidebarOpen ? "open" : ""} ${toggleVisible ? "visible" : ""}`}
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
aria-label={sidebarOpen ? "Collapse panel" : "Open panel"}
|
||||
title={sidebarOpen ? "Collapse panel" : "Open panel"}
|
||||
>
|
||||
{sidebarOpen ? "›" : "‹"}
|
||||
</button>
|
||||
|
||||
<aside className={`sidebar ${sidebarOpen ? "open" : "collapsed"}`}>
|
||||
<div className="sidebar-inner">
|
||||
<AdminPanel
|
||||
status={status}
|
||||
onSourceChanged={handleSourceChanged}
|
||||
onPlaylistReloaded={handlePlaylistReloaded}
|
||||
/>
|
||||
<ChannelList
|
||||
channels={channels}
|
||||
activeChannel={status?.channel_name ?? ""}
|
||||
playlistVersion={status?.playlist_version ?? 0}
|
||||
onRefresh={handleChannelRefresh}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Status banner fixed to the bottom */}
|
||||
<StatusBar status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Channel, StreamStatus, SystemStatus } from "../types";
|
||||
import type { Channel, StreamStatus, SystemStatus, AppConfig } from "../types";
|
||||
|
||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
@@ -8,6 +8,10 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<AppConfig> {
|
||||
return fetchJSON<AppConfig>("/api/config");
|
||||
}
|
||||
|
||||
async function fetchVoid(url: string, init?: RequestInit): Promise<void> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded
|
||||
</button>
|
||||
</div>
|
||||
<button className="reload-btn" onClick={handleReload}>
|
||||
Reload Playlist
|
||||
Refresh Channels / Playlist
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,11 @@ import * as api from "../api/client";
|
||||
interface Props {
|
||||
channels: Channel[];
|
||||
activeChannel: string;
|
||||
playlistVersion: number;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function ChannelList({ channels, activeChannel, onRefresh }: Props) {
|
||||
export default function ChannelList({ channels, activeChannel, playlistVersion, onRefresh }: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [group, setGroup] = useState("");
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
@@ -16,7 +17,7 @@ export default function ChannelList({ channels, activeChannel, onRefresh }: Prop
|
||||
|
||||
useEffect(() => {
|
||||
api.getGroups().then((g) => setGroups(g ?? [])).catch(() => {});
|
||||
}, []);
|
||||
}, [playlistVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFiltered = async () => {
|
||||
|
||||
@@ -1,75 +1,137 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import type { PlaybackMode } from "../types";
|
||||
|
||||
interface Props {
|
||||
live: boolean;
|
||||
mode: PlaybackMode;
|
||||
streamKey: string;
|
||||
}
|
||||
|
||||
export default function Player({ live }: Props) {
|
||||
export default function Player({ live, mode, streamKey }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Tear down any existing instance when stream goes offline
|
||||
const clearRetry = () => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Tear down when stream goes offline
|
||||
if (!live) {
|
||||
clearRetry();
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const src = `http://${window.location.hostname}:8888/live/stream/index.m3u8`;
|
||||
const src = `${window.location.origin}/live/stream/index.m3u8?v=${encodeURIComponent(streamKey)}`;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 6,
|
||||
liveBackBufferLength: 0,
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 30,
|
||||
});
|
||||
const hlsConfig =
|
||||
mode === "llhls"
|
||||
? {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 6,
|
||||
liveBackBufferLength: 0,
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 30,
|
||||
}
|
||||
: {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 10,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
};
|
||||
const hls = new Hls(hlsConfig);
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
setTimeout(() => hls.loadSource(src), 5000);
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(() => hls.loadSource(src), 5000);
|
||||
} else {
|
||||
hls.destroy();
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = src;
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
// Native HLS (iOS Safari) — set src directly and retry on error.
|
||||
const setupNative = () => {
|
||||
video.src = src;
|
||||
video.load();
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
clearRetry();
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(setupNative, 5000);
|
||||
};
|
||||
|
||||
const onStalled = () => {
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(setupNative, 5000);
|
||||
};
|
||||
|
||||
video.addEventListener("playing", onPlaying);
|
||||
video.addEventListener("error", onError);
|
||||
video.addEventListener("stalled", onStalled);
|
||||
setupNative();
|
||||
|
||||
return () => {
|
||||
clearRetry();
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
video.removeEventListener("error", onError);
|
||||
video.removeEventListener("stalled", onStalled);
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
setPlaying(false);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearRetry();
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
setPlaying(false);
|
||||
};
|
||||
}, [live]);
|
||||
}, [live, mode, streamKey]);
|
||||
|
||||
return (
|
||||
<div className="player-container">
|
||||
<video ref={videoRef} className="player-video" controls playsInline />
|
||||
<div className="player-offline">Stream Offline</div>
|
||||
<video ref={videoRef} className="player-video" controls playsInline autoPlay muted />
|
||||
{!playing && <div className="player-offline">Stream Offline</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ export default function StatusBar({ status }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status.upstream_down && (
|
||||
<>
|
||||
<span className="status-separator">|</span>
|
||||
<span className="upstream-down-banner">Upstream down?</span>
|
||||
</>
|
||||
)}
|
||||
{h && h.speed && (
|
||||
<span className="status-health">
|
||||
<span className="status-separator">|</span>
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StreamStatus {
|
||||
channel_name: string;
|
||||
live: boolean;
|
||||
transitioning: boolean;
|
||||
upstream_down: boolean;
|
||||
playlist_version: number;
|
||||
health?: StreamHealth;
|
||||
}
|
||||
|
||||
@@ -33,3 +35,9 @@ export interface SystemStatus {
|
||||
mediamtx: ProcessInfo;
|
||||
ffmpeg: ProcessInfo;
|
||||
}
|
||||
|
||||
export type PlaybackMode = "llhls" | "fmp4" | "standard";
|
||||
|
||||
export interface AppConfig {
|
||||
playback_mode: PlaybackMode;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ rtmp: yes
|
||||
rtmpAddress: :1935
|
||||
|
||||
# HLS server (output to viewers)
|
||||
# Note: hlsVariant / segment / part durations are overridden at runtime by the
|
||||
# backend via MTX_HLS* environment variables, based on PLAYBACK_MODE
|
||||
# (standard = mpegts, fmp4 = fragmented MP4, llhls = lowLatency).
|
||||
hls: yes
|
||||
hlsAddress: :8888
|
||||
hlsAlwaysRemux: yes
|
||||
hlsVariant: mpegts
|
||||
hlsSegmentCount: 7
|
||||
hlsSegmentDuration: 1s
|
||||
hlsSegmentDuration: 2s
|
||||
hlsAllowOrigin: '*'
|
||||
|
||||
# RTSP server (used internally for FFmpeg → MediaMTX push, supports HEVC)
|
||||
|
||||
1
update_url.txt
Normal file
1
update_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://pia.cx/m3u/630xhAmY/smp424GL
|
||||
Reference in New Issue
Block a user