udpate stuff
This commit is contained in:
@@ -88,6 +88,7 @@ func (a *App) HandleSetSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.Status.ChannelName = ""
|
a.Status.ChannelName = ""
|
||||||
|
a.Status.Transitioning = false
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.Status.Source = "iptv"
|
a.Status.Source = "iptv"
|
||||||
a.Status.ChannelName = found.Name
|
a.Status.ChannelName = found.Name
|
||||||
a.Status.Live = true
|
a.Status.Live = true
|
||||||
|
a.Status.Transitioning = true
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[admin] channel set to %s (%s)", found.Name, found.ID)
|
log.Printf("[admin] channel set to %s (%s)", found.Name, found.ID)
|
||||||
@@ -187,3 +189,4 @@ func (a *App) HandleProcessStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(status)
|
json.NewEncoder(w).Encode(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
status := a.Status
|
status := a.Status
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
// Attach live stream health metrics when FFmpeg is running
|
||||||
|
status.Health = a.FFmpeg.Health()
|
||||||
|
|
||||||
|
// Auto-clear transitioning once FFmpeg is producing health data
|
||||||
|
if status.Transitioning && status.Health != nil && status.Health.Speed != "" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.Status.Transitioning = false
|
||||||
|
a.mu.Unlock()
|
||||||
|
status.Transitioning = false
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(status)
|
json.NewEncoder(w).Encode(status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,13 +105,17 @@ func generateID(name, url string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FilterChannels returns channels matching the given search term and/or group.
|
// FilterChannels returns channels matching the given search term and/or group.
|
||||||
|
// Always returns a non-nil slice so it encodes as [] rather than null in JSON.
|
||||||
func FilterChannels(channels []models.Channel, search string, group string) []models.Channel {
|
func FilterChannels(channels []models.Channel, search string, group string) []models.Channel {
|
||||||
if search == "" && group == "" {
|
if search == "" && group == "" {
|
||||||
|
if channels == nil {
|
||||||
|
return []models.Channel{}
|
||||||
|
}
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
|
|
||||||
searchLower := strings.ToLower(search)
|
searchLower := strings.ToLower(search)
|
||||||
var result []models.Channel
|
result := []models.Channel{}
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if group != "" && !strings.EqualFold(ch.Group, group) {
|
if group != "" && !strings.EqualFold(ch.Group, group) {
|
||||||
@@ -127,9 +131,10 @@ func FilterChannels(channels []models.Channel, search string, group string) []mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetGroups returns a deduplicated, sorted list of group names from the channel list.
|
// GetGroups returns a deduplicated, sorted list of group names from the channel list.
|
||||||
|
// Always returns a non-nil slice so it encodes as [] rather than null in JSON.
|
||||||
func GetGroups(channels []models.Channel) []string {
|
func GetGroups(channels []models.Channel) []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var groups []string
|
groups := []string{}
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if ch.Group != "" && !seen[ch.Group] {
|
if ch.Group != "" && !seen[ch.Group] {
|
||||||
|
|||||||
@@ -9,11 +9,24 @@ type Channel struct {
|
|||||||
StreamURL string `json:"stream_url"`
|
StreamURL string `json:"stream_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamHealth holds real-time FFmpeg progress metrics for diagnosing issues.
|
||||||
|
// Speed < 1x indicates the upstream IPTV source can't deliver fast enough.
|
||||||
|
// Drop frames > 0 with speed ~1x suggests local processing issues.
|
||||||
|
type StreamHealth struct {
|
||||||
|
Speed string `json:"speed"` // "1.00x" — upstream delivery rate
|
||||||
|
FPS string `json:"fps"` // frames per second being processed
|
||||||
|
Bitrate string `json:"bitrate"` // output bitrate
|
||||||
|
DropFrames string `json:"drop_frames"` // frames dropped by FFmpeg
|
||||||
|
DupFrames string `json:"dup_frames"` // frames duplicated by FFmpeg
|
||||||
|
}
|
||||||
|
|
||||||
// StreamStatus represents the current streaming state.
|
// StreamStatus represents the current streaming state.
|
||||||
type StreamStatus struct {
|
type StreamStatus struct {
|
||||||
Source string `json:"source"` // "obs" or "iptv"
|
Source string `json:"source"` // "obs" or "iptv"
|
||||||
ChannelName string `json:"channel_name"` // current IPTV channel name (empty if OBS)
|
ChannelName string `json:"channel_name"` // current IPTV channel name (empty if OBS)
|
||||||
Live bool `json:"live"`
|
Live bool `json:"live"`
|
||||||
|
Transitioning bool `json:"transitioning"` // true while switching to a new channel
|
||||||
|
Health *StreamHealth `json:"health,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessInfo represents the status of a managed child process.
|
// ProcessInfo represents the status of a managed child process.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package process
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ type FFmpegManager struct {
|
|||||||
startTime time.Time
|
startTime time.Time
|
||||||
lastError string
|
lastError string
|
||||||
streamURL string
|
streamURL string
|
||||||
|
health models.StreamHealth
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFFmpegManager creates a new FFmpeg process manager.
|
// NewFFmpegManager creates a new FFmpeg process manager.
|
||||||
@@ -24,7 +28,7 @@ func NewFFmpegManager() *FFmpegManager {
|
|||||||
return &FFmpegManager{}
|
return &FFmpegManager{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start spawns an FFmpeg process to pull from streamURL and push RTMP to MediaMTX.
|
// Start spawns an FFmpeg process to pull from streamURL and push to MediaMTX.
|
||||||
// It kills any existing FFmpeg process first.
|
// It kills any existing FFmpeg process first.
|
||||||
func (f *FFmpegManager) Start(streamURL string) error {
|
func (f *FFmpegManager) Start(streamURL string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -34,7 +38,11 @@ func (f *FFmpegManager) Start(streamURL string) error {
|
|||||||
f.stopLocked()
|
f.stopLocked()
|
||||||
|
|
||||||
f.cmd = exec.Command("ffmpeg",
|
f.cmd = exec.Command("ffmpeg",
|
||||||
"-re",
|
"-loglevel", "warning",
|
||||||
|
"-progress", "pipe:1",
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "5",
|
||||||
"-i", streamURL,
|
"-i", streamURL,
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-f", "flv",
|
"-f", "flv",
|
||||||
@@ -42,6 +50,17 @@ func (f *FFmpegManager) Start(streamURL string) error {
|
|||||||
)
|
)
|
||||||
f.streamURL = streamURL
|
f.streamURL = streamURL
|
||||||
f.lastError = ""
|
f.lastError = ""
|
||||||
|
f.health = models.StreamHealth{}
|
||||||
|
|
||||||
|
// Capture stdout for progress stats, stderr for errors
|
||||||
|
stdout, err := f.cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
stderr, err := f.cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := f.cmd.Start(); err != nil {
|
if err := f.cmd.Start(); err != nil {
|
||||||
f.lastError = err.Error()
|
f.lastError = err.Error()
|
||||||
@@ -51,18 +70,59 @@ func (f *FFmpegManager) Start(streamURL string) error {
|
|||||||
f.startTime = time.Now()
|
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, source: %s", f.cmd.Process.Pid, streamURL)
|
||||||
|
|
||||||
// Monitor in background
|
// Pass cmd reference so goroutines can detect process replacement
|
||||||
go f.monitor()
|
cmd := f.cmd
|
||||||
|
go f.parseProgress(stdout)
|
||||||
|
go f.logStderr(stderr)
|
||||||
|
go f.monitor(cmd)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FFmpegManager) monitor() {
|
// parseProgress reads FFmpeg -progress output and updates health stats.
|
||||||
err := f.cmd.Wait()
|
func (f *FFmpegManager) parseProgress(r io.Reader) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
switch k {
|
||||||
|
case "speed":
|
||||||
|
f.health.Speed = v
|
||||||
|
case "fps":
|
||||||
|
f.health.FPS = v
|
||||||
|
case "bitrate":
|
||||||
|
f.health.Bitrate = v
|
||||||
|
case "drop_frames":
|
||||||
|
f.health.DropFrames = v
|
||||||
|
case "dup_frames":
|
||||||
|
f.health.DupFrames = v
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FFmpegManager) logStderr(r io.Reader) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
log.Printf("[ffmpeg] %s", scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FFmpegManager) monitor(cmd *exec.Cmd) {
|
||||||
|
err := cmd.Wait()
|
||||||
|
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
// If the process was replaced by a new Start() call, don't touch state
|
||||||
|
if f.cmd != cmd {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.lastError = err.Error()
|
f.lastError = err.Error()
|
||||||
log.Printf("[ffmpeg] exited: %v", err)
|
log.Printf("[ffmpeg] exited: %v", err)
|
||||||
@@ -96,6 +156,19 @@ func (f *FFmpegManager) stopLocked() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health returns the latest stream health stats from FFmpeg progress.
|
||||||
|
func (f *FFmpegManager) Health() *models.StreamHealth {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
if f.cmd == nil || f.health.Speed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h := f.health // copy
|
||||||
|
return &h
|
||||||
|
}
|
||||||
|
|
||||||
// Status returns the current FFmpeg process status.
|
// Status returns the current FFmpeg process status.
|
||||||
func (f *FFmpegManager) Status() models.ProcessInfo {
|
func (f *FFmpegManager) Status() models.ProcessInfo {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package process
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -54,6 +56,10 @@ func (m *MediaMTXManager) startLocked() error {
|
|||||||
m.stopped = false
|
m.stopped = false
|
||||||
m.stopCh = make(chan struct{})
|
m.stopCh = make(chan struct{})
|
||||||
|
|
||||||
|
// Capture stdout/stderr for logging
|
||||||
|
stdout, _ := m.cmd.StdoutPipe()
|
||||||
|
stderr, _ := m.cmd.StderrPipe()
|
||||||
|
|
||||||
if err := m.cmd.Start(); err != nil {
|
if err := m.cmd.Start(); err != nil {
|
||||||
m.lastError = err.Error()
|
m.lastError = err.Error()
|
||||||
return fmt.Errorf("start mediamtx: %w", err)
|
return fmt.Errorf("start mediamtx: %w", err)
|
||||||
@@ -62,12 +68,22 @@ func (m *MediaMTXManager) startLocked() error {
|
|||||||
m.startTime = time.Now()
|
m.startTime = time.Now()
|
||||||
log.Printf("[mediamtx] started with pid %d", m.cmd.Process.Pid)
|
log.Printf("[mediamtx] started with pid %d", m.cmd.Process.Pid)
|
||||||
|
|
||||||
|
go m.logOutput("stdout", stdout)
|
||||||
|
go m.logOutput("stderr", stderr)
|
||||||
|
|
||||||
// Monitor in background and auto-restart on unexpected exit
|
// Monitor in background and auto-restart on unexpected exit
|
||||||
go m.monitor()
|
go m.monitor()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MediaMTXManager) logOutput(name string, r io.Reader) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
log.Printf("[mediamtx:%s] %s", name, scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MediaMTXManager) monitor() {
|
func (m *MediaMTXManager) monitor() {
|
||||||
err := m.cmd.Wait()
|
err := m.cmd.Wait()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "8888:8888"
|
- "8888:8888"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/playlist.m3u:/data/playlist.m3u:ro
|
- ./real1.m3u:/data/playlist.m3u:ro
|
||||||
environment:
|
environment:
|
||||||
RESTREAMER_PORT: "8080"
|
RESTREAMER_PORT: "8080"
|
||||||
PLAYLIST_PATH: "/data/playlist.m3u"
|
PLAYLIST_PATH: "/data/playlist.m3u"
|
||||||
|
|||||||
@@ -95,6 +95,76 @@ body {
|
|||||||
|
|
||||||
.status-channel {
|
.status-channel {
|
||||||
color: #67e8f9;
|
color: #67e8f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-channel.channel-transitioning {
|
||||||
|
animation: channel-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switching {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fbbf24;
|
||||||
|
background: #713f12;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: switching-blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes channel-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes switching-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-health {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-indicator {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-good {
|
||||||
|
background: #14532d;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-warn {
|
||||||
|
background: #713f12;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bad {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-unknown {
|
||||||
|
background: #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-stats {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-drops {
|
||||||
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls layout */
|
/* Controls layout */
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchChannels();
|
fetchChannels();
|
||||||
const interval = setInterval(fetchStatus, 5000);
|
// Poll faster while transitioning so the UI updates promptly
|
||||||
|
const rate = status?.transitioning ? 1000 : 5000;
|
||||||
|
const interval = setInterval(fetchStatus, rate);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchStatus, fetchChannels]);
|
}, [fetchStatus, fetchChannels, status?.transitioning]);
|
||||||
|
|
||||||
const handleSourceChanged = () => {
|
const handleSourceChanged = () => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
@@ -50,7 +52,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Player />
|
<Player live={status?.live ?? false} />
|
||||||
<StatusBar status={status} />
|
<StatusBar status={status} />
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<AdminPanel
|
<AdminPanel
|
||||||
@@ -60,7 +62,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<ChannelList
|
<ChannelList
|
||||||
channels={channels}
|
channels={channels}
|
||||||
activeChannel={status?.channelName ?? ""}
|
activeChannel={status?.channel_name ?? ""}
|
||||||
onRefresh={handleChannelRefresh}
|
onRefresh={handleChannelRefresh}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ export default function ChannelList({ channels, activeChannel, onRefresh }: Prop
|
|||||||
const [filtered, setFiltered] = useState<Channel[]>(channels);
|
const [filtered, setFiltered] = useState<Channel[]>(channels);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getGroups().then(setGroups).catch(() => {});
|
api.getGroups().then((g) => setGroups(g ?? [])).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFiltered = async () => {
|
const fetchFiltered = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.getChannels(search || undefined, group || undefined);
|
const result = await api.getChannels(search || undefined, group || undefined);
|
||||||
setFiltered(result);
|
setFiltered(result ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
setFiltered(channels);
|
setFiltered(channels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
|
|
||||||
export default function Player() {
|
interface Props {
|
||||||
|
live: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Player({ live }: Props) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
|
||||||
@@ -9,12 +13,28 @@ export default function Player() {
|
|||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const src = `http://${window.location.hostname}:8888/live/stream/`;
|
// Tear down any existing instance when stream goes offline
|
||||||
|
if (!live) {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
video.removeAttribute("src");
|
||||||
|
video.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = `http://${window.location.hostname}:8888/live/stream/index.m3u8`;
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: true,
|
lowLatencyMode: true,
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 6,
|
||||||
|
liveBackBufferLength: 0,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
});
|
});
|
||||||
hlsRef.current = hls;
|
hlsRef.current = hls;
|
||||||
hls.loadSource(src);
|
hls.loadSource(src);
|
||||||
@@ -44,7 +64,7 @@ export default function Player() {
|
|||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [live]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player-container">
|
<div className="player-container">
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ interface Props {
|
|||||||
status: StreamStatus | null;
|
status: StreamStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function healthColor(speed: string): string {
|
||||||
|
const n = parseFloat(speed);
|
||||||
|
if (isNaN(n)) return "health-unknown";
|
||||||
|
if (n >= 0.95) return "health-good";
|
||||||
|
if (n >= 0.8) return "health-warn";
|
||||||
|
return "health-bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthLabel(speed: string): string {
|
||||||
|
const n = parseFloat(speed);
|
||||||
|
if (isNaN(n)) return "?";
|
||||||
|
if (n >= 0.95) return "Good";
|
||||||
|
if (n >= 0.8) return "Slow upstream";
|
||||||
|
return "Upstream too slow";
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatusBar({ status }: Props) {
|
export default function StatusBar({ status }: Props) {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +30,8 @@ export default function StatusBar({ status }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const h = status.health;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-bar">
|
<div className="status-bar">
|
||||||
<span className={`status-dot ${status.live ? "live" : "offline"}`} />
|
<span className={`status-dot ${status.live ? "live" : "offline"}`} />
|
||||||
@@ -24,12 +42,32 @@ export default function StatusBar({ status }: Props) {
|
|||||||
<span className="status-source">
|
<span className="status-source">
|
||||||
Source: {status.source === "obs" ? "OBS" : "IPTV"}
|
Source: {status.source === "obs" ? "OBS" : "IPTV"}
|
||||||
</span>
|
</span>
|
||||||
{status.source === "iptv" && status.channelName && (
|
{status.source === "iptv" && status.channel_name && (
|
||||||
<>
|
<>
|
||||||
<span className="status-separator">|</span>
|
<span className="status-separator">|</span>
|
||||||
<span className="status-channel">{status.channelName}</span>
|
<span className={`status-channel ${status.transitioning ? "channel-transitioning" : ""}`}>
|
||||||
|
{status.channel_name}
|
||||||
|
{status.transitioning && (
|
||||||
|
<span className="channel-switching">Switching...</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{h && h.speed && (
|
||||||
|
<span className="status-health">
|
||||||
|
<span className="status-separator">|</span>
|
||||||
|
<span className={`health-indicator ${healthColor(h.speed)}`}>
|
||||||
|
{healthLabel(h.speed)}
|
||||||
|
</span>
|
||||||
|
<span className="health-stats">
|
||||||
|
{h.speed} / {h.bitrate}
|
||||||
|
{h.fps && h.fps !== "0.00" && <> / {h.fps}fps</>}
|
||||||
|
{h.drop_frames !== "0" && (
|
||||||
|
<span className="health-drops"> / {h.drop_frames} dropped</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ export interface Channel {
|
|||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamHealth {
|
||||||
|
speed: string;
|
||||||
|
fps: string;
|
||||||
|
bitrate: string;
|
||||||
|
drop_frames: string;
|
||||||
|
dup_frames: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StreamStatus {
|
export interface StreamStatus {
|
||||||
source: "obs" | "iptv";
|
source: "obs" | "iptv";
|
||||||
channelName: string;
|
channel_name: string;
|
||||||
live: boolean;
|
live: boolean;
|
||||||
|
transitioning: boolean;
|
||||||
|
health?: StreamHealth;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessInfo {
|
export interface ProcessInfo {
|
||||||
|
|||||||
11
mediamtx.yml
11
mediamtx.yml
@@ -6,6 +6,10 @@
|
|||||||
logLevel: info
|
logLevel: info
|
||||||
logDestinations: [stdout]
|
logDestinations: [stdout]
|
||||||
|
|
||||||
|
# REST API (for diagnostics)
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
|
||||||
# RTMP server (ingest from OBS / FFmpeg)
|
# RTMP server (ingest from OBS / FFmpeg)
|
||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
rtmpAddress: :1935
|
||||||
@@ -14,12 +18,15 @@ rtmpAddress: :1935
|
|||||||
hls: yes
|
hls: yes
|
||||||
hlsAddress: :8888
|
hlsAddress: :8888
|
||||||
hlsAlwaysRemux: yes
|
hlsAlwaysRemux: yes
|
||||||
hlsSegmentCount: 3
|
hlsSegmentCount: 7
|
||||||
hlsSegmentDuration: 1s
|
hlsSegmentDuration: 1s
|
||||||
hlsAllowOrigin: '*'
|
hlsAllowOrigin: '*'
|
||||||
|
|
||||||
|
# RTSP server (used internally for FFmpeg → MediaMTX push, supports HEVC)
|
||||||
|
rtsp: yes
|
||||||
|
rtspAddress: :8554
|
||||||
|
|
||||||
# Disable unused protocols
|
# Disable unused protocols
|
||||||
rtsp: no
|
|
||||||
webrtc: no
|
webrtc: no
|
||||||
srt: no
|
srt: no
|
||||||
record: no
|
record: no
|
||||||
|
|||||||
Reference in New Issue
Block a user