From c0f5c7427d69451431c4d195114c0a54b09365ce Mon Sep 17 00:00:00 2001 From: Alex Matthews Date: Tue, 24 Mar 2026 18:47:27 +0000 Subject: [PATCH 1/3] feat: add RTSP server, Chromecast WebRTC casting, and multi-session support Add three major features to the JetKVM firmware: 1. **RTSP server** (`rtsp.go`): Exposes the HDMI capture H.264 stream as `rtsp://:8554/stream` using gortsplib v5. Supports multiple concurrent clients with SPS/PPS caching for late-joiners. Configurable via settings UI (enable/disable, port). 2. **Chromecast casting** (`chromecast.go`): Low-latency WebRTC streaming to Chromecast / Google TV devices via a custom Cast receiver application. Uses the CASTV2 protocol directly with a buffered connection wrapper to prevent channel deadlocks. The receiver HTML (`cast-receiver/`) is hosted externally on any HTTPS server and establishes a direct WebRTC connection to the JetKVM. Includes mDNS device discovery and a Cast button in the web UI toolbar. See `cast-receiver/README.md` for setup instructions. 3. **Multi-session WebRTC**: Replaces the `currentSession` singleton with a mutex-protected session registry. Video frames are fanned out to all active WebRTC sessions, allowing the browser and Chromecast to stream simultaneously. All event broadcasting (USB, serial, OTA, network, video) updated to iterate all sessions. Co-Authored-By: Claude Opus 4.6 (1M context) --- cast-receiver/README.md | 92 +++++ cast-receiver/index.html | 159 ++++++++ chromecast.go | 361 +++++++++++++++++++ cloud.go | 25 +- config.go | 6 + go.mod | 51 ++- go.sum | 132 +++++-- hw.go | 2 +- jsonrpc.go | 16 +- log.go | 2 + main.go | 10 +- native.go | 20 +- network.go | 6 +- ota.go | 6 +- rtsp.go | 268 ++++++++++++++ serial.go | 8 +- ui/src/components/ActionBar.tsx | 3 + ui/src/components/CastButton.tsx | 177 +++++++++ ui/src/hooks/useCast.ts | 117 ++++++ ui/src/routes/devices.$id.settings.video.tsx | 130 +++++++ usb.go | 16 +- video.go | 4 +- web.go | 81 +++-- webrtc.go | 12 +- 24 files changed, 1561 insertions(+), 143 deletions(-) create mode 100644 cast-receiver/README.md create mode 100644 cast-receiver/index.html create mode 100644 chromecast.go create mode 100644 rtsp.go create mode 100644 ui/src/components/CastButton.tsx create mode 100644 ui/src/hooks/useCast.ts diff --git a/cast-receiver/README.md b/cast-receiver/README.md new file mode 100644 index 000000000..29dfcf1cc --- /dev/null +++ b/cast-receiver/README.md @@ -0,0 +1,92 @@ +# Chromecast Custom Receiver Setup + +This directory contains the custom Google Cast receiver application used for low-latency WebRTC streaming from JetKVM to Chromecast / Google TV devices. + +## Prerequisites + +- A Google account (personal Gmail works) +- A Chromecast, Chromecast with Google TV, or Google TV Streamer on the same LAN as the JetKVM +- An HTTPS web server to host the receiver HTML (e.g., Caddy, nginx, GitHub Pages) + +## Setup Steps + +### 1. Register as a Cast Developer + +1. Go to the [Google Cast SDK Developer Console](https://cast.google.com/publish) +2. Pay the one-time $5 registration fee +3. Accept the Terms of Service + +### 2. Create a Custom Receiver Application + +1. Click **Add New Application** +2. Select **Custom Receiver** +3. Fill in: + - **Name**: `JetKVM` (or any name you prefer) + - **Receiver Application URL**: The HTTPS URL where you'll host `index.html` (e.g., `https://yourdomain.com/cast-receiver/index.html`) +4. Click **Save** +5. Note the **Application ID** (e.g., `F311D863`) + +### 3. Register Your Device for Testing + +Unpublished apps only work on registered test devices: + +1. In the Cast Developer Console, go to **Cast Receiver Devices** +2. Click **Add New Device** +3. Enter the device's **Cast serial number**: + - On Google TV Streamer: **Settings > System > About > Cast serial number** (NOT the Android serial) + - On Chromecast: Check the box or **Google Home app > Device > Settings > Serial number** +4. Wait **15 minutes** for registration to propagate +5. **Hard reboot** the device (unplug power, wait 10 seconds, plug back in) + +### 4. Host the Receiver HTML + +The `index.html` file must be served over **HTTPS** with a valid TLS certificate. Options: + +**Caddy (recommended for self-hosting):** +``` +yourdomain.com { + handle_path /cast-receiver/* { + root * /path/to/cast-receiver + file_server + } +} +``` + +**GitHub Pages:** +1. Create a repository and push `index.html` +2. Enable GitHub Pages in repository settings +3. Use the resulting `https://username.github.io/repo-name/index.html` URL + +### 5. Configure the JetKVM + +1. Open the JetKVM web interface +2. Go to **Settings > Video** +3. Set the **Cast Receiver App ID** to the Application ID from step 2 +4. Click **Apply** + +### 6. Cast + +1. In the JetKVM web interface, click the **Cast** button in the toolbar +2. Select your Chromecast / Google TV device from the list +3. The receiver loads on the TV and establishes a direct WebRTC connection to the JetKVM +4. Expected latency: ~300-500ms on LAN + +## How It Works + +1. The JetKVM connects to the Chromecast via the CASTV2 protocol (TLS on port 8009) +2. It launches the custom receiver app by Application ID +3. The Chromecast loads the receiver HTML from your HTTPS server +4. The JetKVM sends its IP address to the receiver via a custom Cast namespace +5. The receiver opens a WebSocket to the JetKVM for WebRTC signaling +6. A peer-to-peer WebRTC connection is established for H.264 video streaming + +## Troubleshooting + +- **"Timeout waiting for custom receiver"**: The device is not registered as a test device, or the 15-minute propagation hasn't completed. Hard reboot the device after waiting. +- **Black screen on TV, no video**: Check the receiver's console via `chrome://inspect` in Chrome (device must be on the same network). Look for WebSocket connection errors. +- **Cast button shows no devices**: Ensure the Chromecast is on the same LAN/subnet as the JetKVM. mDNS discovery requires multicast to work. +- **Mixed content errors**: The receiver HTML must be served over HTTPS. The WebSocket connection to the JetKVM uses `ws://` which is allowed from Cast receiver contexts. + +## Publishing (Optional) + +Once testing is complete, you can publish the app in the Cast Developer Console to make it available on all Chromecast devices without per-device registration. diff --git a/cast-receiver/index.html b/cast-receiver/index.html new file mode 100644 index 000000000..ef92339ea --- /dev/null +++ b/cast-receiver/index.html @@ -0,0 +1,159 @@ + + + + + + + +
Cast receiver loading...
+ + + + + + + + + diff --git a/chromecast.go b/chromecast.go new file mode 100644 index 000000000..4510f6234 --- /dev/null +++ b/chromecast.go @@ -0,0 +1,361 @@ +package kvm + +import ( + "context" + "encoding/json" + "fmt" + "net" + "time" + + "github.com/jetkvm/kvm/internal/sync" + "github.com/vishen/go-chromecast/cast" + pb "github.com/vishen/go-chromecast/cast/proto" + castdns "github.com/vishen/go-chromecast/dns" +) + +// --------------------------------------------------------------------------- +// bufferedConn wraps cast.Connection with a buffered message channel to +// prevent the unbuffered recvMsgChan from deadlocking the receiveLoop. +// --------------------------------------------------------------------------- + +type bufferedConn struct { + inner *cast.Connection + bufferedCh chan *pb.CastMessage +} + +func newBufferedConn() *bufferedConn { + return &bufferedConn{ + inner: cast.NewConnection(), + } +} + +func (b *bufferedConn) Start(addr string, port int) error { + if err := b.inner.Start(addr, port); err != nil { + return err + } + // Pump from the unbuffered channel to a buffered one so receiveLoop never blocks + b.bufferedCh = make(chan *pb.CastMessage, 32) + go func() { + for msg := range b.inner.MsgChan() { + select { + case b.bufferedCh <- msg: + default: + // Drop if buffer full (shouldn't happen) + } + } + close(b.bufferedCh) + }() + return nil +} + +func (b *bufferedConn) Send(requestID int, payload cast.Payload, sourceID, destinationID, namespace string) error { + return b.inner.Send(requestID, payload, sourceID, destinationID, namespace) +} + +func (b *bufferedConn) MsgChan() chan *pb.CastMessage { return b.bufferedCh } +func (b *bufferedConn) Close() error { return b.inner.Close() } + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ( + jetkvmNamespace = "urn:x-cast:com.jetkvm.cast" + nsConnection = "urn:x-cast:com.google.cast.tp.connection" + nsReceiver = "urn:x-cast:com.google.cast.receiver" + castSender = "sender-0" + castReceiver = "receiver-0" +) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ChromecastDevice struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Address string `json:"address"` + Port int `json:"port"` +} + +type CastStatus struct { + Active bool `json:"active"` + DeviceName string `json:"deviceName,omitempty"` + Error string `json:"error,omitempty"` +} + +// castMessage is a simple payload for custom namespace messages. +type castMessage struct { + Type string `json:"type"` + IP string `json:"ip,omitempty"` +} + +func (m *castMessage) SetRequestId(_ int) {} + +// receiverStatus is the subset of RECEIVER_STATUS we care about. +type receiverStatus struct { + Status struct { + Applications []struct { + AppId string `json:"appId"` + TransportId string `json:"transportId"` + } `json:"applications"` + } `json:"status"` +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +var castState struct { + mu sync.Mutex + active bool + deviceName string + conn *bufferedConn +} + +// --------------------------------------------------------------------------- +// mDNS discovery +// --------------------------------------------------------------------------- + +func rpcDiscoverChromecasts() ([]ChromecastDevice, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + entryCh, err := castdns.DiscoverCastDNSEntries(ctx, nil) + if err != nil { + chromecastLogger.Warn().Err(err).Msg("mDNS discovery failed") + return nil, fmt.Errorf("mDNS discovery failed: %w", err) + } + + var devices []ChromecastDevice + for entry := range entryCh { + devices = append(devices, ChromecastDevice{ + Name: entry.GetName(), + UUID: entry.GetUUID(), + Address: entry.GetAddr(), + Port: entry.GetPort(), + }) + } + + chromecastLogger.Info().Int("count", len(devices)).Msg("Chromecast discovery complete") + return devices, nil +} + +// --------------------------------------------------------------------------- +// Cast control — WebRTC via custom receiver +// --------------------------------------------------------------------------- + +func getDeviceLocalIP() (string, error) { + state := rpcGetNetworkState() + if state != nil && state.IPv4Address != "" { + return state.IPv4Address, nil + } + addrs, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + return "", fmt.Errorf("no suitable local IP found") +} + +func rpcStartCasting(address string, port int) error { + castState.mu.Lock() + defer castState.mu.Unlock() + + if castState.active { + return fmt.Errorf("already casting") + } + + localIP, err := getDeviceLocalIP() + if err != nil { + return fmt.Errorf("cannot determine local IP: %w", err) + } + + chromecastLogger.Info().Str("address", address).Int("port", port).Msg("starting cast") + + // Connect to Chromecast with buffered message channel + conn := newBufferedConn() + if err := conn.Start(address, port); err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + msgCh := conn.MsgChan() + transportCh := make(chan string, 1) + errCh := make(chan error, 1) + + go func() { + timeout := time.After(15 * time.Second) + for { + select { + case msg, ok := <-msgCh: + if !ok { + errCh <- fmt.Errorf("message channel closed") + return + } + if msg.PayloadUtf8 == nil { + continue + } + payload := *msg.PayloadUtf8 + chromecastLogger.Info().Str("payload", payload).Msg("received cast message") + + var generic map[string]interface{} + if err := json.Unmarshal([]byte(payload), &generic); err != nil { + continue + } + msgType, _ := generic["type"].(string) + + switch msgType { + case "LAUNCH_ERROR": + reason, _ := generic["reason"].(string) + errCh <- fmt.Errorf("LAUNCH_ERROR: %s (is the device registered as a test device?)", reason) + return + + case "LAUNCH_STATUS": + // App is launching — poll for updated status after a brief delay + go func() { + time.Sleep(2 * time.Second) + poll := cast.GetStatusHeader + poll.SetRequestId(10) + conn.Send(10, &poll, castSender, castReceiver, nsReceiver) //nolint:errcheck + }() + + case "RECEIVER_STATUS": + var status receiverStatus + if err := json.Unmarshal([]byte(payload), &status); err != nil { + continue + } + for _, app := range status.Status.Applications { + if app.AppId == config.CastReceiverAppID { + transportCh <- app.TransportId + return + } + } + } + case <-timeout: + errCh <- fmt.Errorf("timeout waiting for custom receiver to start") + return + } + } + }() + + // Send CONNECT to the receiver platform + chromecastLogger.Info().Msg("sending CONNECT") + connectHeader := cast.ConnectHeader // local copy + if err := conn.Send(1, &connectHeader, castSender, castReceiver, nsConnection); err != nil { + conn.Close() //nolint:errcheck + return fmt.Errorf("CONNECT failed: %w", err) + } + + // Request initial status (triggers Chromecast to send RECEIVER_STATUS) + getStatus := cast.GetStatusHeader // local copy + getStatus.SetRequestId(2) + if err := conn.Send(2, &getStatus, castSender, castReceiver, nsReceiver); err != nil { + conn.Close() //nolint:errcheck + return fmt.Errorf("GET_STATUS failed: %w", err) + } + + // Launch the custom receiver app + chromecastLogger.Info().Str("appId", config.CastReceiverAppID).Msg("sending LAUNCH") + launch := cast.LaunchRequest{ + PayloadHeader: cast.LaunchHeader, + AppId: config.CastReceiverAppID, + } + launch.SetRequestId(3) + if err := conn.Send(3, &launch, castSender, castReceiver, nsReceiver); err != nil { + conn.Close() //nolint:errcheck + return fmt.Errorf("LAUNCH failed: %w", err) + } + + // Wait for the custom receiver to start + var transportID string + select { + case transportID = <-transportCh: + chromecastLogger.Info().Str("transportId", transportID).Msg("custom receiver launched") + case err := <-errCh: + conn.Close() //nolint:errcheck + return fmt.Errorf("failed to launch receiver: %w", err) + } + + // Connect to the app's transport + if err := conn.Send(3, &cast.ConnectHeader, castSender, transportID, nsConnection); err != nil { + conn.Close() //nolint:errcheck + return fmt.Errorf("transport CONNECT failed: %w", err) + } + + // Send the JetKVM IP to the receiver so it can establish WebRTC + chromecastLogger.Info().Str("ip", localIP).Msg("sending JetKVM IP to receiver") + if err := conn.Send(4, &castMessage{ + Type: "connect", + IP: localIP, + }, castSender, transportID, jetkvmNamespace); err != nil { + conn.Close() //nolint:errcheck + return fmt.Errorf("failed to send connect message: %w", err) + } + + // Keep connection alive (receiveLoop handles PING/PONG automatically) + castState.active = true + castState.deviceName = fmt.Sprintf("%s:%d", address, port) + castState.conn = conn + + chromecastLogger.Info().Str("device", castState.deviceName).Msg("casting started (WebRTC)") + return nil +} + +func rpcStopCasting() error { + castState.mu.Lock() + defer castState.mu.Unlock() + + if !castState.active { + return nil + } + + if castState.conn != nil { + // Send stop message to receiver (best-effort) + _ = castState.conn.Send(0, &cast.StopHeader, castSender, castReceiver, nsReceiver) + _ = castState.conn.Close() + castState.conn = nil + } + + castState.active = false + castState.deviceName = "" + + chromecastLogger.Info().Msg("casting stopped") + return nil +} + +func rpcGetCastingStatus() CastStatus { + castState.mu.Lock() + defer castState.mu.Unlock() + + return CastStatus{ + Active: castState.active, + DeviceName: castState.deviceName, + } +} + +type CastConfig struct { + ReceiverAppID string `json:"receiverAppId"` +} + +func rpcGetCastConfig() CastConfig { + return CastConfig{ + ReceiverAppID: config.CastReceiverAppID, + } +} + +func rpcSetCastConfig(receiverAppID string) error { + if receiverAppID == "" { + return fmt.Errorf("receiver app ID cannot be empty") + } + old := config.CastReceiverAppID + config.CastReceiverAppID = receiverAppID + if err := SaveConfig(); err != nil { + config.CastReceiverAppID = old + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} diff --git a/cloud.go b/cloud.go index 9a35256cc..2ecc6b9e6 100644 --- a/cloud.go +++ b/cloud.go @@ -434,7 +434,7 @@ func handleSessionRequest( isCloudConnection bool, source string, scopedLogger *zerolog.Logger, -) error { +) (*Session, error) { var sourceType string if isCloudConnection { sourceType = "cloud" @@ -451,7 +451,7 @@ func handleSessionRequest( // If the message is from the cloud, we need to authenticate the session. if isCloudConnection { if err := authenticateSession(ctx, c, req); err != nil { - return err + return nil, err } } @@ -465,32 +465,21 @@ func handleSessionRequest( }) if err != nil { _ = wsjson.Write(context.Background(), c, gin.H{"error": err}) - return err + return nil, err } sd, err := session.ExchangeOffer(req.Sd) if err != nil { _ = wsjson.Write(context.Background(), c, gin.H{"error": err}) - return err - } - if currentSession != nil { - writeJSONRPCEvent("otherSessionConnected", nil, currentSession) - peerConn := currentSession.peerConnection - go func() { - time.Sleep(1 * time.Second) - _ = peerConn.Close() - }() + return nil, err } - cloudLogger.Info().Interface("session", session).Msg("new session accepted") - cloudLogger.Trace().Interface("session", session).Msg("new session accepted") + addSession(session) - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() + cloudLogger.Info().Interface("session", session).Msg("new session accepted") - currentSession = session _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) - return nil + return session, nil } func RunWebsocketClient() { diff --git a/config.go b/config.go index 7c5738f91..d2ea5d50a 100644 --- a/config.go +++ b/config.go @@ -117,6 +117,9 @@ type Config struct { VideoQualityFactor float64 `json:"video_quality_factor"` NativeMaxRestart uint `json:"native_max_restart_attempts"` MqttConfig *MQTTConfig `json:"mqtt_config"` + RTSPEnabled bool `json:"rtsp_enabled"` + RTSPPort int `json:"rtsp_port"` + CastReceiverAppID string `json:"cast_receiver_app_id"` } // GetUpdateAPIURL returns the update API URL @@ -208,6 +211,9 @@ func getDefaultConfig() Config { EnableActions: true, DebounceMs: 500, }, + RTSPEnabled: true, + RTSPPort: 8554, + CastReceiverAppID: "F311D863", } } diff --git a/go.mod b/go.mod index f4388a4db..abfe82228 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,17 @@ module github.com/jetkvm/kvm -go 1.24.4 +go 1.25.0 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/ProtonMail/go-crypto v1.1.5 github.com/beevik/ntp v1.5.0 + github.com/bluenviron/gortsplib/v5 v5.5.0 github.com/caarlos0/env/v11 v11.3.1 github.com/coder/websocket v1.8.14 github.com/coreos/go-oidc/v3 v3.16.0 github.com/creack/pty v1.1.24 + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 @@ -20,10 +22,10 @@ require ( github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e github.com/mdlayher/ndp v1.1.0 - github.com/pion/ice/v4 v4.1.0 + github.com/pion/ice/v4 v4.2.1 github.com/pion/logging v0.2.4 github.com/pion/mdns/v2 v2.1.0 - github.com/pion/webrtc/v4 v4.2.1 + github.com/pion/webrtc/v4 v4.2.9 github.com/pojntfx/go-nbd v0.3.2 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.2 @@ -34,11 +36,12 @@ require ( github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f github.com/stretchr/testify v1.11.1 github.com/vearutop/statigz v1.5.0 + github.com/vishen/go-chromecast v0.3.4 github.com/vishvananda/netlink v1.3.1 go.bug.st/serial v1.6.4 - golang.org/x/crypto v0.43.0 - golang.org/x/net v0.46.0 - golang.org/x/sys v0.37.0 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 ) @@ -47,14 +50,16 @@ replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20 require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/bluenviron/mediacommon/v2 v2.8.3 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -62,7 +67,9 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/grandcat/zeroconf v1.0.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -72,28 +79,31 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/packet v1.1.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect + github.com/miekg/dns v1.1.62 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.14 // indirect github.com/pilebones/go-udev v0.9.1 // indirect - github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.9 // indirect - github.com/pion/interceptor v0.1.42 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/interceptor v0.1.44 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.16 // indirect - github.com/pion/rtp v1.8.27 // indirect - github.com/pion/sctp v1.9.0 // indirect - github.com/pion/sdp/v3 v3.0.17 // indirect - github.com/pion/srtp/v3 v3.0.9 // indirect - github.com/pion/stun/v3 v3.0.2 // indirect - github.com/pion/transport/v3 v3.1.1 // indirect - github.com/pion/turn/v4 v4.1.3 // indirect + github.com/pion/rtp v1.10.1 // indirect + github.com/pion/sctp v1.9.2 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.10 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect github.com/ugorji/go/codec v1.3.0 // indirect @@ -101,9 +111,12 @@ require ( github.com/wlynxg/anet v0.0.5 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.10.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4223acb59..cfe3a5b5a 100644 --- a/go.sum +++ b/go.sum @@ -8,14 +8,22 @@ github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4= github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bluenviron/gortsplib/v5 v5.5.0 h1:2wlUKhlSw1ptKEVnkEuZMpQuN8Xt311Fdd/SB124JPA= +github.com/bluenviron/gortsplib/v5 v5.5.0/go.mod h1:otcPqR836QZej/EYx7njn8vl4TLj8Ya3QAf+GBwh2cQ= +github.com/bluenviron/mediacommon/v2 v2.8.3 h1:T6xb7ZK3eBixi/HynzhtGRCEIrazwcmGIeu0WDTVISY= +github.com/bluenviron/mediacommon/v2 v2.8.3/go.mod h1:CsYjGgzIz8RbloQf4BHR4uReogZsB4PEKWfePVIzJv8= github.com/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE= github.com/bool64/dev v0.2.39/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs= @@ -69,6 +77,8 @@ github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -78,6 +88,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= +github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0= @@ -93,6 +105,8 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -121,6 +135,9 @@ github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -134,14 +151,14 @@ github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= -github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= -github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM= -github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= -github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU= -github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4= -github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= -github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY= +github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= +github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= +github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= @@ -150,22 +167,25 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.8.27 h1:kbWTdZr62RDlYjatVAW4qFwrAu9XcGnwMsofCfAHlOU= -github.com/pion/rtp v1.8.27/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.9.0 h1:vajCA6G+1/SEi4vpPmDnpRNXwDNBmAXFBvJx0Le9HrI= -github.com/pion/sctp v1.9.0/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY= -github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= -github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= -github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY= -github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8= -github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= -github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= +github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= +github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= -github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA= -github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A= -github.com/pion/webrtc/v4 v4.2.1 h1:QgIfJeXf9dg++35y4z8GK3oXHcxWf0y2tUstCry0/V8= -github.com/pion/webrtc/v4 v4.2.1/go.mod h1:YDcAacHK1DZkkn1vwFn3yiXbixCBsEDaCNzg9PPAACk= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE= +github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -187,6 +207,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU= github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -195,6 +217,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -208,12 +231,16 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk= github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk= +github.com/vishen/go-chromecast v0.3.4 h1:ELRwOoNaxwIsKCXuxCXXku92+H/qKLj3a6ZN6LDD+H8= +github.com/vishen/go-chromecast v0.3.4/go.mod h1:9ht6970KP5YmO0WpJJPMfInai2HA5w+q+UWId3QLxBc= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -234,26 +261,63 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= diff --git a/hw.go b/hw.go index d544cf1cf..ef84c8282 100644 --- a/hw.go +++ b/hw.go @@ -34,7 +34,7 @@ func extractSerialNumber() (string, error) { func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error { logger.Info().Dur("delayMs", delay).Msg("reboot requested") - writeJSONRPCEvent("willReboot", postRebootAction, currentSession) + forEachSession(func(s *Session) { writeJSONRPCEvent("willReboot", postRebootAction, s) }) time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent nativeInstance.SwitchToScreenIfDifferent("rebooting_screen") diff --git a/jsonrpc.go b/jsonrpc.go index 8b1c07ce5..048a8fafe 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1053,18 +1053,14 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { IsPaste: true, } - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) - } + forEachSession(func(sess *Session) { sess.reportHidRPCKeyboardMacroState(s) }) err := rpcDoExecuteKeyboardMacro(ctx, macro) setKeyboardMacroCancel(nil) s.State = false - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) - } + forEachSession(func(sess *Session) { sess.reportHidRPCKeyboardMacroState(s) }) return err } @@ -1220,4 +1216,12 @@ var rpcHandlers = map[string]RPCHandler{ "setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}}, "getMqttStatus": {Func: rpcGetMqttStatus}, "testMqttConnection": {Func: rpcTestMqttConnection, Params: []string{"settings"}}, + "getRTSPConfig": {Func: rpcGetRTSPConfig}, + "setRTSPConfig": {Func: rpcSetRTSPConfig, Params: []string{"enabled", "port"}}, + "getCastConfig": {Func: rpcGetCastConfig}, + "setCastConfig": {Func: rpcSetCastConfig, Params: []string{"receiverAppId"}}, + "discoverChromecasts": {Func: rpcDiscoverChromecasts}, + "startCasting": {Func: rpcStartCasting, Params: []string{"address", "port"}}, + "stopCasting": {Func: rpcStopCasting}, + "getCastingStatus": {Func: rpcGetCastingStatus}, } diff --git a/log.go b/log.go index f84b02f0e..35fbc81b1 100644 --- a/log.go +++ b/log.go @@ -30,6 +30,8 @@ var ( wolLogger = logging.GetSubsystemLogger("wol") usbLogger = logging.GetSubsystemLogger("usb") tailscaleLogger = logging.GetSubsystemLogger("tailscale") + rtspLogger = logging.GetSubsystemLogger("rtsp") + chromecastLogger = logging.GetSubsystemLogger("chromecast") // external components ginLogger = logging.GetSubsystemLogger("gin") ) diff --git a/main.go b/main.go index ac9ec1bb4..2fb171e04 100644 --- a/main.go +++ b/main.go @@ -126,6 +126,14 @@ func Main() { // start video sleep mode timer startVideoSleepModeTicker() + // Start RTSP server if enabled + if config.RTSPEnabled { + if err := StartRTSPServer(config.RTSPPort); err != nil { + rtspLogger.Error().Err(err).Msg("failed to start RTSP server") + } + } + + go func() { // wait for 15 minutes before starting auto-update checks // this is to avoid interfering with initial setup processes @@ -140,7 +148,7 @@ func Main() { continue } - if currentSession != nil { + if anySession() { logger.Debug().Msg("skipping update since a session is active") time.Sleep(1 * time.Minute) continue diff --git a/native.go b/native.go index 89d991391..a944800c4 100644 --- a/native.go +++ b/native.go @@ -68,20 +68,26 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { } }, OnVideoFrameReceived: func(frame []byte, duration time.Duration) { - if currentSession != nil { - err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}) - if err != nil { - nativeLogger.Warn().Err(err).Msg("error writing sample") + // 1. WebRTC: fan out to ALL active sessions + forEachSession(func(s *Session) { + if err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}); err != nil { + nativeLogger.Warn().Err(err).Msg("error writing sample to session") } + }) + + // 2. RTSP server (always, if enabled and running) + if rtspServer != nil { + rtspServer.WriteNALU(frame, duration) } + }, GetSessionInfo: func() diagnostics.SessionInfo { info := diagnostics.SessionInfo{ ActiveSessions: getActiveSessions(), - HasCurrentSession: currentSession != nil, + HasCurrentSession: anySession(), } - if currentSession != nil { - sessionInfo := currentSession.GetDiagnosticsInfo() + if s := getFirstSession(); s != nil { + sessionInfo := s.GetDiagnosticsInfo() info.ICEConnectionState = sessionInfo.ICEConnectionState info.SignalingState = sessionInfo.SignalingState info.ConnectionState = sessionInfo.ConnectionState diff --git a/network.go b/network.go index 8168250df..25702ef8d 100644 --- a/network.go +++ b/network.go @@ -122,9 +122,7 @@ func networkStateChanged(_ string, state types.InterfaceState) { // do not block the main thread go waitCtrlAndRequestDisplayUpdate(false, "network_state_changed") - if currentSession != nil { - writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession) - } + forEachSession(func(s *Session) { writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), s) }) if state.Online { networkLogger.Info().Msg("network state changed to online, triggering time sync") @@ -317,7 +315,7 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er // If reboot required, send willReboot event before applying network config if rebootRequired { l.Info().Msg("Sending willReboot event before applying network config") - writeJSONRPCEvent("willReboot", postRebootAction, currentSession) + forEachSession(func(s *Session) { writeJSONRPCEvent("willReboot", postRebootAction, s) }) } _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) diff --git a/ota.go b/ota.go index 9ff68d058..85ab48003 100644 --- a/ota.go +++ b/ota.go @@ -45,7 +45,7 @@ func initOta() { } }, OnProgressUpdate: func(progress float32) { - writeJSONRPCEvent("otaProgress", progress, currentSession) + forEachSession(func(s *Session) { writeJSONRPCEvent("otaProgress", progress, s) }) // Also update MQTT update state for HA progress feedback if mqttManager != nil { mqttManager.publishUpdateState() @@ -56,13 +56,13 @@ func initOta() { func triggerOTAStateUpdate(state *ota.RPCState) { go func() { - if currentSession == nil || (otaState == nil && state == nil) { + if !anySession() || (otaState == nil && state == nil) { return } if state == nil { state = otaState.ToRPCState() } - writeJSONRPCEvent("otaState", state, currentSession) + forEachSession(func(s *Session) { writeJSONRPCEvent("otaState", state, s) }) }() } diff --git a/rtsp.go b/rtsp.go new file mode 100644 index 000000000..efe767902 --- /dev/null +++ b/rtsp.go @@ -0,0 +1,268 @@ +package kvm + +import ( + "fmt" + "time" + + "github.com/bluenviron/gortsplib/v5" + "github.com/bluenviron/gortsplib/v5/pkg/base" + "github.com/bluenviron/gortsplib/v5/pkg/description" + "github.com/bluenviron/gortsplib/v5/pkg/format" + "github.com/bluenviron/gortsplib/v5/pkg/format/rtph264" + "github.com/jetkvm/kvm/internal/sync" +) + +var rtspServer *RTSPServer + +// RTSPServer wraps gortsplib.Server to serve the H.264 capture stream over RTSP. +type RTSPServer struct { + server *gortsplib.Server + stream *gortsplib.ServerStream + + media *description.Media + h264Format *format.H264 + encoder *rtph264.Encoder + + // SPS/PPS cache — updated from live NALUs, included in SDP for late-joiners + mu sync.RWMutex + sps []byte + pps []byte + + // cumulative PTS for RTP timestamps + pts time.Duration +} + +// StartRTSPServer creates and starts the RTSP server on the given port. +func StartRTSPServer(port int) error { + h264Format := &format.H264{ + PayloadTyp: 96, + PacketizationMode: 1, + } + + media := &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{h264Format}, + } + + desc := &description.Session{ + Medias: []*description.Media{media}, + } + + rs := &RTSPServer{ + h264Format: h264Format, + media: media, + } + + rs.server = &gortsplib.Server{ + Handler: rs, + RTSPAddress: fmt.Sprintf(":%d", port), + } + + if err := rs.server.Start(); err != nil { + return fmt.Errorf("failed to start RTSP server: %w", err) + } + + rs.stream = &gortsplib.ServerStream{ + Server: rs.server, + Desc: desc, + } + if err := rs.stream.Initialize(); err != nil { + rs.server.Close() + return fmt.Errorf("failed to initialize RTSP stream: %w", err) + } + + rs.encoder = &rtph264.Encoder{ + PayloadType: 96, + } + if err := rs.encoder.Init(); err != nil { + rs.stream.Close() + rs.server.Close() + return fmt.Errorf("failed to init H264 RTP encoder: %w", err) + } + + rtspServer = rs + rtspLogger.Info().Int("port", port).Msg("RTSP server started") + return nil +} + +// StopRTSPServer shuts down the RTSP server. +func StopRTSPServer() { + if rtspServer == nil { + return + } + rtspServer.stream.Close() + rtspServer.server.Close() + rtspServer = nil + rtspLogger.Info().Msg("RTSP server stopped") +} + +// WriteNALU receives a raw H.264 Annex B frame and fans it out to all RTSP clients. +func (rs *RTSPServer) WriteNALU(frame []byte, duration time.Duration) { + rs.pts += duration + + nalus := splitAnnexB(frame) + if len(nalus) == 0 { + return + } + + // Cache SPS/PPS and update the format so the SDP stays current for late-joiners. + for _, nalu := range nalus { + if len(nalu) == 0 { + continue + } + naluType := nalu[0] & 0x1F + switch naluType { + case 7: // SPS + rs.mu.Lock() + rs.sps = make([]byte, len(nalu)) + copy(rs.sps, nalu) + rs.h264Format.SafeSetParams(rs.sps, rs.pps) + rs.mu.Unlock() + case 8: // PPS + rs.mu.Lock() + rs.pps = make([]byte, len(nalu)) + copy(rs.pps, nalu) + rs.h264Format.SafeSetParams(rs.sps, rs.pps) + rs.mu.Unlock() + } + } + + // Encode NALUs into RTP packets and write to all connected RTSP sessions. + pkts, err := rs.encoder.Encode(nalus) + if err != nil { + return + } + + for _, pkt := range pkts { + rs.stream.WritePacketRTP(rs.media, pkt) //nolint:errcheck + } +} + +// --------------------------------------------------------------------------- +// gortsplib ServerHandler interface implementations +// --------------------------------------------------------------------------- + +func (rs *RTSPServer) OnConnOpen(ctx *gortsplib.ServerHandlerOnConnOpenCtx) { + rtspLogger.Info().Str("addr", ctx.Conn.NetConn().RemoteAddr().String()).Msg("RTSP client connected") +} + +func (rs *RTSPServer) OnConnClose(ctx *gortsplib.ServerHandlerOnConnCloseCtx) { + rtspLogger.Info().Str("addr", ctx.Conn.NetConn().RemoteAddr().String()).Msg("RTSP client disconnected") +} + +func (rs *RTSPServer) OnSessionOpen(ctx *gortsplib.ServerHandlerOnSessionOpenCtx) { + rtspLogger.Debug().Msg("RTSP session opened") +} + +func (rs *RTSPServer) OnSessionClose(ctx *gortsplib.ServerHandlerOnSessionCloseCtx) { + rtspLogger.Debug().Msg("RTSP session closed") +} + +func (rs *RTSPServer) OnDescribe(_ *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) { + return &base.Response{StatusCode: base.StatusOK}, rs.stream, nil +} + +func (rs *RTSPServer) OnSetup(_ *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) { + return &base.Response{StatusCode: base.StatusOK}, rs.stream, nil +} + +func (rs *RTSPServer) OnPlay(_ *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error) { + return &base.Response{StatusCode: base.StatusOK}, nil +} + +// --------------------------------------------------------------------------- +// Annex B parser — splits an Annex B byte stream into individual NAL units +// --------------------------------------------------------------------------- + +func splitAnnexB(frame []byte) [][]byte { + var nalus [][]byte + start := -1 + n := len(frame) + + for i := 0; i <= n-3; i++ { + if frame[i] == 0 && frame[i+1] == 0 { + // 4-byte start code 00 00 00 01 + if i <= n-4 && frame[i+2] == 0 && frame[i+3] == 1 { + if start >= 0 { + nalus = append(nalus, frame[start:i]) + } + start = i + 4 + i += 3 + continue + } + // 3-byte start code 00 00 01 + if frame[i+2] == 1 { + if start >= 0 { + nalus = append(nalus, frame[start:i]) + } + start = i + 3 + i += 2 + continue + } + } + } + + // Trailing NALU + if start >= 0 && start < n { + nalus = append(nalus, frame[start:n]) + } + + // If no start codes were found, treat the entire frame as a single raw NALU. + if len(nalus) == 0 && n > 0 { + return [][]byte{frame} + } + + return nalus +} + +// --------------------------------------------------------------------------- +// JSON-RPC methods for RTSP configuration +// --------------------------------------------------------------------------- + +type RTSPConfig struct { + Enabled bool `json:"enabled"` + Port int `json:"port"` +} + +func rpcGetRTSPConfig() RTSPConfig { + return RTSPConfig{ + Enabled: config.RTSPEnabled, + Port: config.RTSPPort, + } +} + +func rpcSetRTSPConfig(enabled bool, port int) error { + if port <= 0 || port > 65535 { + port = 8554 + } + + wasEnabled := config.RTSPEnabled + wasPort := config.RTSPPort + + config.RTSPEnabled = enabled + config.RTSPPort = port + if err := SaveConfig(); err != nil { + // Rollback + config.RTSPEnabled = wasEnabled + config.RTSPPort = wasPort + return fmt.Errorf("failed to save config: %w", err) + } + + // Apply changes live + if wasEnabled && !enabled { + StopRTSPServer() + } else if !wasEnabled && enabled { + if err := StartRTSPServer(port); err != nil { + rtspLogger.Error().Err(err).Msg("failed to start RTSP server after config change") + return err + } + } else if enabled && wasPort != port { + StopRTSPServer() + if err := StartRTSPServer(port); err != nil { + rtspLogger.Error().Err(err).Msg("failed to restart RTSP server on new port") + return err + } + } + + return nil +} diff --git a/serial.go b/serial.go index 6189c1e03..e5d085f3f 100644 --- a/serial.go +++ b/serial.go @@ -68,9 +68,7 @@ func runATXControl() { HDD: newLedHDDState, } - if currentSession != nil { - writeJSONRPCEvent("atxState", atxState, currentSession) - } + forEachSession(func(s *Session) { writeJSONRPCEvent("atxState", atxState, s) }) if mqttManager != nil { mqttManager.publishATXState(atxState) @@ -238,9 +236,7 @@ func runDCControl() { // Update Prometheus metrics updateDCMetrics(snapshot) - if currentSession != nil { - writeJSONRPCEvent("dcState", snapshot, currentSession) - } + forEachSession(func(s *Session) { writeJSONRPCEvent("dcState", snapshot, s) }) if mqttManager != nil { mqttManager.publishDCState(snapshot) diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 7bde652c4..dfd5a25f4 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -16,6 +16,7 @@ import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { CommandLineIcon } from "@heroicons/react/20/solid"; import { SplitButtonGroup, SplitButtonPrimary, SplitButtonCaret } from "@components/SplitButton"; +import CastButton from "@components/CastButton"; import { cx } from "@/cva.config"; import { @@ -288,6 +289,8 @@ export default function Actionbar({ }} /> + + {!isDetachedWindow && (
+ ); +} + +function DevicePicker({ + devices, + isDiscovering, + isStarting, + error, + onSelect, + onRefresh, +}: { + devices: ChromecastDevice[]; + isDiscovering: boolean; + isStarting: boolean; + error: string | null; + onSelect: (device: ChromecastDevice) => void; + onRefresh: () => void; +}) { + if (isStarting) { + return ( +
+ + + Starting stream... + +
+ ); + } + + return ( +
+
+ + Chromecast Devices + + +
+ + {isDiscovering && devices.length === 0 && ( +
+ + + Searching... + +
+ )} + + {!isDiscovering && devices.length === 0 && !error && ( +

+ No devices found. Make sure your Chromecast is on the same network. +

+ )} + + {error && ( +

{error}

+ )} + + {devices.length > 0 && ( +
    + {devices.map(device => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/ui/src/hooks/useCast.ts b/ui/src/hooks/useCast.ts new file mode 100644 index 000000000..eafaf3e26 --- /dev/null +++ b/ui/src/hooks/useCast.ts @@ -0,0 +1,117 @@ +import { useCallback, useState } from "react"; + +import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; +import notifications from "@/notifications"; + +export interface ChromecastDevice { + name: string; + uuid: string; + address: string; + port: number; +} + +export interface CastState { + isCasting: boolean; + activeDevice: { name: string; address: string; port: number } | null; + discoveredDevices: ChromecastDevice[]; + isDiscovering: boolean; + isStarting: boolean; + error: string | null; +} + +export function useCast() { + const { send } = useJsonRpc(); + const [state, setState] = useState({ + isCasting: false, + activeDevice: null, + discoveredDevices: [], + isDiscovering: false, + isStarting: false, + error: null, + }); + + const discoverDevices = useCallback(() => { + setState(s => ({ ...s, isDiscovering: true, error: null })); + send("discoverChromecasts", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + setState(s => ({ + ...s, + isDiscovering: false, + error: resp.error?.message || "Discovery failed", + })); + return; + } + setState(s => ({ + ...s, + isDiscovering: false, + discoveredDevices: (resp.result as ChromecastDevice[]) || [], + })); + }); + }, [send]); + + const startCasting = useCallback( + (device: ChromecastDevice) => { + setState(s => ({ ...s, isStarting: true, error: null })); + send( + "startCasting", + { address: device.address, port: device.port }, + (resp: JsonRpcResponse) => { + if ("error" in resp) { + const msg = resp.error?.message || "Failed to start casting"; + setState(s => ({ ...s, isStarting: false, error: msg })); + notifications.error(`Cast failed: ${msg}`); + return; + } + setState(s => ({ + ...s, + isStarting: false, + isCasting: true, + activeDevice: { + name: device.name, + address: device.address, + port: device.port, + }, + })); + notifications.success(`Casting to ${device.name}`); + }, + ); + }, + [send], + ); + + const stopCasting = useCallback(() => { + send("stopCasting", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error("Failed to stop casting"); + return; + } + setState(s => ({ + ...s, + isCasting: false, + activeDevice: null, + })); + }); + }, [send]); + + const refreshStatus = useCallback(() => { + send("getCastingStatus", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const status = resp.result as { active: boolean; deviceName: string }; + setState(s => ({ + ...s, + isCasting: status.active, + activeDevice: status.active + ? { name: status.deviceName, address: "", port: 0 } + : null, + })); + }); + }, [send]); + + return { + ...state, + discoverDevices, + startCasting, + stopCasting, + refreshStatus, + }; +} diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index 528f86024..8852790c7 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsStore } from "@hooks/stores"; import { Button } from "@components/Button"; import { TextAreaWithLabel } from "@components/TextArea"; +import { InputFieldWithLabel } from "@components/InputField"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; @@ -134,6 +135,68 @@ export default function SettingsVideoRoute() { }); }; + // RTSP settings + const [rtspEnabled, setRtspEnabled] = useState(true); + const [rtspPort, setRtspPort] = useState("8554"); + + // Cast settings + const [castAppId, setCastAppId] = useState("F311D863"); + + useEffect(() => { + send("getRTSPConfig", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const cfg = resp.result as { enabled: boolean; port: number }; + setRtspEnabled(cfg.enabled); + setRtspPort(String(cfg.port)); + }); + send("getCastConfig", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const cfg = resp.result as { receiverAppId: string }; + setCastAppId(cfg.receiverAppId); + }); + }, [send]); + + const handleRtspToggle = (enabled: boolean) => { + const port = parseInt(rtspPort, 10) || 8554; + send("setRTSPConfig", { enabled, port }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update RTSP config: ${resp.error?.message}`); + return; + } + setRtspEnabled(enabled); + notifications.success(enabled ? "RTSP server enabled" : "RTSP server disabled"); + }); + }; + + const handleCastAppIdChange = () => { + if (!castAppId.trim()) { + notifications.error("App ID cannot be empty"); + return; + } + send("setCastConfig", { receiverAppId: castAppId.trim() }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update Cast config: ${resp.error?.message}`); + return; + } + notifications.success("Cast receiver app ID updated"); + }); + }; + + const handleRtspPortChange = () => { + const port = parseInt(rtspPort, 10); + if (!port || port < 1 || port > 65535) { + notifications.error("Invalid port number"); + return; + } + send("setRTSPConfig", { enabled: rtspEnabled, port }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to update RTSP port: ${resp.error?.message}`); + return; + } + notifications.success(`RTSP port set to ${port}`); + }); + }; + const [debugInfo, setDebugInfo] = useState(null); const [debugInfoLoading, setDebugInfoLoading] = useState(false); const getDebugInfo = useCallback(() => { @@ -302,6 +365,73 @@ export default function SettingsVideoRoute() { + {/* RTSP Streaming */} +
+ + handleRtspToggle(e.target.value === "enabled")} + /> + + {rtspEnabled && ( + + :${rtspPort}/stream`} + > +
+ setRtspPort(e.target.value)} + size="SM" + /> +
+
+
+ )} +
+ + {/* Chromecast Settings */} +
+ +
+ setCastAppId(e.target.value)} + size="SM" + /> +
+
+
+ {debugMode && (
0 +} + +// getFirstSession returns an arbitrary session (for diagnostics). May return nil. +func getFirstSession() *Session { + sessionsMu.RLock() + defer sessionsMu.RUnlock() + for s := range sessions { + return s + } + return nil +} func handleWebRTCSession(c *gin.Context) { var req WebRTCSessionRequest @@ -257,19 +300,7 @@ func handleWebRTCSession(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err}) return } - if currentSession != nil { - writeJSONRPCEvent("otherSessionConnected", nil, currentSession) - peerConn := currentSession.peerConnection - go func() { - time.Sleep(1 * time.Second) - _ = peerConn.Close() - }() - } - - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() - - currentSession = session + addSession(session) c.JSON(http.StatusOK, gin.H{"sd": sd}) } @@ -334,6 +365,8 @@ func handleWebRTCSignalWsMessages( connectionID string, scopedLogger *zerolog.Logger, ) error { + var wsSession *Session // tracks the session for this WebSocket connection + runCtx, cancelRun := context.WithCancel(context.Background()) defer func() { if isCloudConnection { @@ -464,11 +497,13 @@ func handleWebRTCSignalWsMessages( metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc() metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime() - err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l) + var newSession *Session + newSession, err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l) if err != nil { l.Warn().Str("error", err.Error()).Msg("error starting new session") continue } + wsSession = newSession } else if message.Type == "new-ice-candidate" { l.Info().Str("data", string(message.Data)).Msg("The client sent us a new ICE candidate") var candidate webrtc.ICECandidateInit @@ -486,13 +521,13 @@ func handleWebRTCSignalWsMessages( l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate") - if currentSession == nil { - l.Warn().Msg("no current session, skipping incoming ICE candidate") + if wsSession == nil { + l.Warn().Msg("no session for this connection, skipping incoming ICE candidate") continue } - l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session") - if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil { + l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to session") + if err = wsSession.peerConnection.AddICECandidate(candidate); err != nil { l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection") } } @@ -955,10 +990,10 @@ func handleDiagnosticsDownload(c *gin.Context) { GetSessionInfo: func() diagnostics.SessionInfo { info := diagnostics.SessionInfo{ ActiveSessions: getActiveSessions(), - HasCurrentSession: currentSession != nil, + HasCurrentSession: anySession(), } - if currentSession != nil { - sessionInfo := currentSession.GetDiagnosticsInfo() + if s := getFirstSession(); s != nil { + sessionInfo := s.GetDiagnosticsInfo() info.ICEConnectionState = sessionInfo.ICEConnectionState info.SignalingState = sessionInfo.SignalingState info.ConnectionState = sessionInfo.ConnectionState diff --git a/webrtc.go b/webrtc.go index 757961563..9ab60d17e 100644 --- a/webrtc.go +++ b/webrtc.go @@ -419,12 +419,8 @@ func newSession(config SessionConfig) (*Session, error) { _ = peerConnection.Close() } if connectionState == webrtc.ICEConnectionStateClosed { - scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media") - if session == currentSession { - // Cancel any ongoing keyboard report multi when session closes - cancelKeyboardMacro() - currentSession = nil - } + scopedLogger.Debug().Msg("ICE Connection State is closed, cleaning up session") + removeSession(session) // Stop RPC processor if session.rpcQueue != nil { close(session.rpcQueue) @@ -462,12 +458,12 @@ func newSession(config SessionConfig) (*Session, error) { } func onActiveSessionsChanged() { - notifyFailsafeMode(currentSession) + forEachSession(notifyFailsafeMode) requestDisplayUpdate(false, "active_sessions_changed") } func onFirstSessionConnected() { - notifyFailsafeMode(currentSession) + forEachSession(notifyFailsafeMode) _ = nativeInstance.VideoStart() stopVideoSleepModeTicker() } From 4320f2eaac46f1e70654ed4622e25819f6ebd512 Mon Sep 17 00:00:00 2001 From: Alex Matthews Date: Tue, 24 Mar 2026 21:20:47 +0000 Subject: [PATCH 2/3] feat(cast): add preferred device support and quick cast RPC - Add CastPreferredDevice to config (persisted to kvm_config.json) - Star icon in Cast dropdown to set/clear preferred device - Preferred device shown in Settings > Video with clear button - rpcQuickCast: casts to preferred device, or first discovered if none set - rpcSetPreferredCastDevice: save/clear preferred device via RPC - LCD touch screen can call quickCast for one-tap casting Co-Authored-By: Claude Opus 4.6 (1M context) --- chromecast.go | 62 +++++++++++++++++++- config.go | 7 ++- jsonrpc.go | 2 + ui/src/components/CastButton.tsx | 51 ++++++++++++++-- ui/src/hooks/useCast.ts | 59 ++++++++++++++++++- ui/src/routes/devices.$id.settings.video.tsx | 35 ++++++++++- 6 files changed, 205 insertions(+), 11 deletions(-) diff --git a/chromecast.go b/chromecast.go index 4510f6234..346a74ee0 100644 --- a/chromecast.go +++ b/chromecast.go @@ -84,6 +84,13 @@ type CastStatus struct { Error string `json:"error,omitempty"` } +// CastPreferredDevice is the saved preferred casting target. +type CastPreferredDevice struct { + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port"` +} + // castMessage is a simple payload for custom namespace messages. type castMessage struct { Type string `json:"type"` @@ -338,12 +345,14 @@ func rpcGetCastingStatus() CastStatus { } type CastConfig struct { - ReceiverAppID string `json:"receiverAppId"` + ReceiverAppID string `json:"receiverAppId"` + PreferredDevice *CastPreferredDevice `json:"preferredDevice"` } func rpcGetCastConfig() CastConfig { return CastConfig{ - ReceiverAppID: config.CastReceiverAppID, + ReceiverAppID: config.CastReceiverAppID, + PreferredDevice: config.CastPreferredDevice, } } @@ -359,3 +368,52 @@ func rpcSetCastConfig(receiverAppID string) error { } return nil } + +func rpcSetPreferredCastDevice(name string, address string, port int) error { + if address == "" { + // Clear preferred device + config.CastPreferredDevice = nil + } else { + config.CastPreferredDevice = &CastPreferredDevice{ + Name: name, + Address: address, + Port: port, + } + } + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + chromecastLogger.Info().Str("name", name).Str("address", address).Msg("preferred cast device updated") + return nil +} + +// rpcQuickCast casts to the preferred device, or the first discovered device if none set. +// Called from the LCD touch screen. +func rpcQuickCast() error { + castState.mu.Lock() + if castState.active { + castState.mu.Unlock() + return rpcStopCasting() + } + castState.mu.Unlock() + + // Use preferred device if set + if dev := config.CastPreferredDevice; dev != nil { + chromecastLogger.Info().Str("name", dev.Name).Msg("quick cast to preferred device") + return rpcStartCasting(dev.Address, dev.Port) + } + + // Otherwise discover and use the first found + chromecastLogger.Info().Msg("quick cast: no preferred device, discovering...") + devices, err := rpcDiscoverChromecasts() + if err != nil { + return fmt.Errorf("discovery failed: %w", err) + } + if len(devices) == 0 { + return fmt.Errorf("no Chromecast devices found") + } + + dev := devices[0] + chromecastLogger.Info().Str("name", dev.Name).Msg("quick cast to first discovered device") + return rpcStartCasting(dev.Address, dev.Port) +} diff --git a/config.go b/config.go index d2ea5d50a..fe09daafa 100644 --- a/config.go +++ b/config.go @@ -117,9 +117,10 @@ type Config struct { VideoQualityFactor float64 `json:"video_quality_factor"` NativeMaxRestart uint `json:"native_max_restart_attempts"` MqttConfig *MQTTConfig `json:"mqtt_config"` - RTSPEnabled bool `json:"rtsp_enabled"` - RTSPPort int `json:"rtsp_port"` - CastReceiverAppID string `json:"cast_receiver_app_id"` + RTSPEnabled bool `json:"rtsp_enabled"` + RTSPPort int `json:"rtsp_port"` + CastReceiverAppID string `json:"cast_receiver_app_id"` + CastPreferredDevice *CastPreferredDevice `json:"cast_preferred_device,omitempty"` } // GetUpdateAPIURL returns the update API URL diff --git a/jsonrpc.go b/jsonrpc.go index 048a8fafe..553d9db10 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1220,8 +1220,10 @@ var rpcHandlers = map[string]RPCHandler{ "setRTSPConfig": {Func: rpcSetRTSPConfig, Params: []string{"enabled", "port"}}, "getCastConfig": {Func: rpcGetCastConfig}, "setCastConfig": {Func: rpcSetCastConfig, Params: []string{"receiverAppId"}}, + "setPreferredCastDevice": {Func: rpcSetPreferredCastDevice, Params: []string{"name", "address", "port"}}, "discoverChromecasts": {Func: rpcDiscoverChromecasts}, "startCasting": {Func: rpcStartCasting, Params: []string{"address", "port"}}, "stopCasting": {Func: rpcStopCasting}, "getCastingStatus": {Func: rpcGetCastingStatus}, + "quickCast": {Func: rpcQuickCast}, } diff --git a/ui/src/components/CastButton.tsx b/ui/src/components/CastButton.tsx index ca3187599..456a8f78a 100644 --- a/ui/src/components/CastButton.tsx +++ b/ui/src/components/CastButton.tsx @@ -1,5 +1,5 @@ import { Fragment, useEffect } from "react"; -import { LuCast, LuLoader, LuRefreshCw } from "react-icons/lu"; +import { LuCast, LuLoader, LuRefreshCw, LuStar } from "react-icons/lu"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { cx } from "@/cva.config"; @@ -16,9 +16,11 @@ export default function CastButton() { isDiscovering, isStarting, error, + preferredDevice, discoverDevices, startCasting, stopCasting, + setPreferredDevice, refreshStatus, } = useCast(); @@ -69,7 +71,9 @@ export default function CastButton() { isDiscovering={isDiscovering} isStarting={isStarting} error={error} + preferredDevice={preferredDevice} onSelect={startCasting} + onSetPreferred={setPreferredDevice} onRefresh={discoverDevices} /> )} @@ -101,14 +105,18 @@ function DevicePicker({ isDiscovering, isStarting, error, + preferredDevice, onSelect, + onSetPreferred, onRefresh, }: { devices: ChromecastDevice[]; isDiscovering: boolean; isStarting: boolean; error: string | null; + preferredDevice: { name: string; address: string; port: number } | null; onSelect: (device: ChromecastDevice) => void; + onSetPreferred: (device: ChromecastDevice | null) => void; onRefresh: () => void; }) { if (isStarting) { @@ -122,6 +130,10 @@ function DevicePicker({ ); } + const isPreferred = (device: ChromecastDevice) => + preferredDevice?.address === device.address && + preferredDevice?.port === device.port; + return (
@@ -161,12 +173,43 @@ function DevicePicker({ {devices.length > 0 && (
    {devices.map(device => ( -
  • +
  • +
  • ))} diff --git a/ui/src/hooks/useCast.ts b/ui/src/hooks/useCast.ts index eafaf3e26..e40f71f98 100644 --- a/ui/src/hooks/useCast.ts +++ b/ui/src/hooks/useCast.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; @@ -10,6 +10,12 @@ export interface ChromecastDevice { port: number; } +export interface PreferredDevice { + name: string; + address: string; + port: number; +} + export interface CastState { isCasting: boolean; activeDevice: { name: string; address: string; port: number } | null; @@ -17,6 +23,7 @@ export interface CastState { isDiscovering: boolean; isStarting: boolean; error: string | null; + preferredDevice: PreferredDevice | null; } export function useCast() { @@ -28,8 +35,29 @@ export function useCast() { isDiscovering: false, isStarting: false, error: null, + preferredDevice: null, }); + // Load preferred device and casting status on mount + useEffect(() => { + send("getCastConfig", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const cfg = resp.result as { preferredDevice: PreferredDevice | null }; + setState(s => ({ ...s, preferredDevice: cfg.preferredDevice })); + }); + send("getCastingStatus", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const status = resp.result as { active: boolean; deviceName: string }; + setState(s => ({ + ...s, + isCasting: status.active, + activeDevice: status.active + ? { name: status.deviceName, address: "", port: 0 } + : null, + })); + }); + }, [send]); + const discoverDevices = useCallback(() => { setState(s => ({ ...s, isDiscovering: true, error: null })); send("discoverChromecasts", {}, (resp: JsonRpcResponse) => { @@ -93,6 +121,34 @@ export function useCast() { }); }, [send]); + const setPreferredDevice = useCallback( + (device: ChromecastDevice | null) => { + const name = device?.name || ""; + const address = device?.address || ""; + const port = device?.port || 0; + send( + "setPreferredCastDevice", + { name, address, port }, + (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error("Failed to set preferred device"); + return; + } + const pref = device + ? { name: device.name, address: device.address, port: device.port } + : null; + setState(s => ({ ...s, preferredDevice: pref })); + notifications.success( + device + ? `${device.name} set as preferred cast device` + : "Preferred cast device cleared", + ); + }, + ); + }, + [send], + ); + const refreshStatus = useCallback(() => { send("getCastingStatus", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; @@ -112,6 +168,7 @@ export function useCast() { discoverDevices, startCasting, stopCasting, + setPreferredDevice, refreshStatus, }; } diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index 8852790c7..fe9ed670b 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -141,6 +141,7 @@ export default function SettingsVideoRoute() { // Cast settings const [castAppId, setCastAppId] = useState("F311D863"); + const [preferredDeviceName, setPreferredDeviceName] = useState(null); useEffect(() => { send("getRTSPConfig", {}, (resp: JsonRpcResponse) => { @@ -151,8 +152,12 @@ export default function SettingsVideoRoute() { }); send("getCastConfig", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - const cfg = resp.result as { receiverAppId: string }; + const cfg = resp.result as { + receiverAppId: string; + preferredDevice: { name: string; address: string; port: number } | null; + }; setCastAppId(cfg.receiverAppId); + setPreferredDeviceName(cfg.preferredDevice?.name || null); }); }, [send]); @@ -410,6 +415,34 @@ export default function SettingsVideoRoute() { {/* Chromecast Settings */}
    + + {preferredDeviceName ? ( +