diff --git a/Dockerfile b/Dockerfile index e62b1a0..cb70e8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/backend/api/admin.go b/backend/api/admin.go index a846d92..2c21750 100644 --- a/backend/api/admin.go +++ b/backend/api/admin.go @@ -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) } - diff --git a/backend/api/playlist.go b/backend/api/playlist.go new file mode 100644 index 0000000..7c9bc76 --- /dev/null +++ b/backend/api/playlist.go @@ -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 +} diff --git a/backend/api/status.go b/backend/api/status.go index f69fbcf..7c3aab5 100644 --- a/backend/api/status.go +++ b/backend/api/status.go @@ -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") diff --git a/backend/main.go b/backend/main.go index 589c8c6..648292f 100644 --- a/backend/main.go +++ b/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() { diff --git a/backend/models/types.go b/backend/models/types.go index 3b437e5..8b5ca23 100644 --- a/backend/models/types.go +++ b/backend/models/types.go @@ -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. diff --git a/backend/process/ffmpeg.go b/backend/process/ffmpeg.go index e8429f9..5af8056 100644 --- a/backend/process/ffmpeg.go +++ b/backend/process/ffmpeg.go @@ -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 diff --git a/backend/process/mediamtx.go b/backend/process/mediamtx.go index 4cf2fb2..23fe3eb 100644 --- a/backend/process/mediamtx.go +++ b/backend/process/mediamtx.go @@ -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{}) diff --git a/docker-compose.yml b/docker-compose.yml index a94cac3..c29f19e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -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... + }, + }, +]) +``` diff --git a/frontend/src/App.css b/frontend/src/App.css index 524279a..6c82fa2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f341bab..2de0db4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [channels, setChannels] = useState([]); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [playbackMode, setPlaybackMode] = useState("standard"); + const [toggleVisible, setToggleVisible] = useState(false); + const hideTimer = useRef | 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 (
- - -
- - +
+ + {/* Collapsible right sidebar */} + + + + + {/* Status banner fixed to the bottom */} +
); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a7ab72d..ed2e07e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Channel, StreamStatus, SystemStatus } from "../types"; +import type { Channel, StreamStatus, SystemStatus, AppConfig } from "../types"; async function fetchJSON(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); @@ -8,6 +8,10 @@ async function fetchJSON(url: string, init?: RequestInit): Promise { return res.json(); } +export function getConfig(): Promise { + return fetchJSON("/api/config"); +} + async function fetchVoid(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); if (!res.ok) { diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 40fad82..45fffec 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -46,7 +46,7 @@ export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded ); diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx index c13d99d..8e9a337 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -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([]); @@ -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 () => { diff --git a/frontend/src/components/Player.tsx b/frontend/src/components/Player.tsx index a7c2887..43a4584 100644 --- a/frontend/src/components/Player.tsx +++ b/frontend/src/components/Player.tsx @@ -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(null); const hlsRef = useRef(null); + const retryTimerRef = useRef | 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 (
-
); } diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index bebfd14..2cba4c1 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -53,6 +53,12 @@ export default function StatusBar({ status }: Props) { )} + {status.upstream_down && ( + <> + | + Upstream down? + + )} {h && h.speed && ( | diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d273709..62f1b63 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; +} diff --git a/mediamtx.yml b/mediamtx.yml index 33fc8e2..d7f75ed 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -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) diff --git a/update_url.txt b/update_url.txt new file mode 100644 index 0000000..9456f78 --- /dev/null +++ b/update_url.txt @@ -0,0 +1 @@ +https://pia.cx/m3u/630xhAmY/smp424GL