From a7d7574e7090f5c0b03bbcae2b5a3aec452a2ec3 Mon Sep 17 00:00:00 2001 From: watermeko <61347352+watermeko@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:48:52 +0800 Subject: [PATCH] fix: warn about mixed H264 stream modes (#843) --- server/service/stream/direct/h264.go | 4 + server/service/stream/direct/streamer.go | 1 + server/service/stream/h264_mode.go | 172 ++++++++++++++++++++++ server/service/stream/webrtc/h264.go | 5 +- server/service/stream/webrtc/manager.go | 37 +++-- server/service/stream/webrtc/signaling.go | 78 +++++++++- server/service/stream/webrtc/types.go | 7 +- server/service/ws/h264_mode.go | 93 ++++++++++++ server/service/ws/service.go | 1 + web/src/i18n/locales/ca.ts | 5 + web/src/i18n/locales/cz.ts | 5 + web/src/i18n/locales/da.ts | 5 + web/src/i18n/locales/de.ts | 5 + web/src/i18n/locales/en.ts | 5 + web/src/i18n/locales/es.ts | 5 + web/src/i18n/locales/fr.ts | 5 + web/src/i18n/locales/hu.ts | 5 + web/src/i18n/locales/id.ts | 5 + web/src/i18n/locales/it.ts | 5 + web/src/i18n/locales/ja.ts | 5 + web/src/i18n/locales/ko.ts | 5 + web/src/i18n/locales/nb.ts | 5 + web/src/i18n/locales/nl.ts | 5 + web/src/i18n/locales/pl.ts | 5 + web/src/i18n/locales/pt_br.ts | 5 + web/src/i18n/locales/ru.ts | 5 + web/src/i18n/locales/se.ts | 5 + web/src/i18n/locales/th.ts | 5 + web/src/i18n/locales/tr.ts | 5 + web/src/i18n/locales/uk.ts | 5 + web/src/i18n/locales/vi.ts | 5 + web/src/i18n/locales/zh.ts | 5 + web/src/i18n/locales/zh_tw.ts | 5 + web/src/pages/desktop/index.tsx | 3 +- web/src/pages/desktop/notification.tsx | 112 +++++++++++--- 35 files changed, 588 insertions(+), 45 deletions(-) create mode 100644 server/service/stream/h264_mode.go create mode 100644 server/service/ws/h264_mode.go diff --git a/server/service/stream/direct/h264.go b/server/service/stream/direct/h264.go index b2813dc..600a52d 100644 --- a/server/service/stream/direct/h264.go +++ b/server/service/stream/direct/h264.go @@ -1,6 +1,7 @@ package direct import ( + "NanoKVM-Server/service/stream" "net/http" "time" @@ -36,6 +37,9 @@ func Connect(c *gin.Context) { streamer.addClient(ws) defer streamer.removeClient(ws) + unregisterMode := stream.RegisterH264Mode(stream.H264ModeDirect) + defer unregisterMode() + for { if _, _, err := ws.NextReader(); err != nil { log.Debugf("failed to read message (client disconnected): %s", err) diff --git a/server/service/stream/direct/streamer.go b/server/service/stream/direct/streamer.go index 1401fed..7c336ed 100644 --- a/server/service/stream/direct/streamer.go +++ b/server/service/stream/direct/streamer.go @@ -152,6 +152,7 @@ func (s *Streamer) send(clients []*websocket.Conn, isKeyFrame byte, timestamp in log.Errorf("failed to write message to client %s: %s.", client.RemoteAddr(), err) s.removeClient(client) + _ = client.Close() } } diff --git a/server/service/stream/h264_mode.go b/server/service/stream/h264_mode.go new file mode 100644 index 0000000..bc7691f --- /dev/null +++ b/server/service/stream/h264_mode.go @@ -0,0 +1,172 @@ +package stream + +import ( + "sync" + + "github.com/google/uuid" +) + +const ( + H264ModeStatusEvent = "h264-mode-status" + H264ModeDirect = "direct" + H264ModeWebRTC = "webrtc" +) + +type H264ModeStatus struct { + Generation string `json:"generation"` + Revision uint64 `json:"revision"` + Direct int `json:"direct"` + WebRTC int `json:"webrtc"` + Mixed bool `json:"mixed"` +} + +type H264ModeStatusSubscriber func(H264ModeStatus) + +var defaultH264ModeStore = newH264ModeStore() + +func RegisterH264Mode(mode string) func() { + return defaultH264ModeStore.register(mode) +} + +func SubscribeH264ModeStatus(subscriber H264ModeStatusSubscriber) func() { + return defaultH264ModeStore.subscribe(subscriber) +} + +func CurrentH264ModeStatus() H264ModeStatus { + return defaultH264ModeStore.current() +} + +type h264ModeStore struct { + mutex sync.Mutex + counts map[string]int + status H264ModeStatus + subscribers map[int]H264ModeStatusSubscriber + nextID int + pending []h264ModeStatusNotification + notify chan struct{} +} + +type h264ModeStatusNotification struct { + status H264ModeStatus + subscribers []H264ModeStatusSubscriber +} + +func newH264ModeStore() *h264ModeStore { + store := &h264ModeStore{ + counts: make(map[string]int), + subscribers: make(map[int]H264ModeStatusSubscriber), + status: H264ModeStatus{ + Generation: uuid.NewString(), + }, + notify: make(chan struct{}, 1), + } + go store.run() + + return store +} + +func (s *h264ModeStore) register(mode string) func() { + if !isH264Mode(mode) { + return func() {} + } + + s.update(mode, 1) + + var once sync.Once + return func() { + once.Do(func() { + s.update(mode, -1) + }) + } +} + +func (s *h264ModeStore) subscribe(subscriber H264ModeStatusSubscriber) func() { + s.mutex.Lock() + id := s.nextID + s.nextID++ + s.subscribers[id] = subscriber + s.mutex.Unlock() + + return func() { + s.mutex.Lock() + delete(s.subscribers, id) + s.mutex.Unlock() + } +} + +func (s *h264ModeStore) current() H264ModeStatus { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.status +} + +func (s *h264ModeStore) update(mode string, delta int) { + s.mutex.Lock() + previous := s.status + s.counts[mode] += delta + if s.counts[mode] < 0 { + s.counts[mode] = 0 + } + + next := H264ModeStatus{ + Generation: previous.Generation, + Revision: previous.Revision + 1, + Direct: s.counts[H264ModeDirect], + WebRTC: s.counts[H264ModeWebRTC], + } + next.Mixed = next.Direct > 0 && next.WebRTC > 0 + s.status = next + + if previous.Mixed == next.Mixed { + s.mutex.Unlock() + return + } + + subscribers := make([]H264ModeStatusSubscriber, 0, len(s.subscribers)) + for _, subscriber := range s.subscribers { + subscribers = append(subscribers, subscriber) + } + s.pending = append(s.pending, h264ModeStatusNotification{ + status: next, + subscribers: subscribers, + }) + s.mutex.Unlock() + + select { + case s.notify <- struct{}{}: + default: + } +} + +func (s *h264ModeStore) run() { + for range s.notify { + for { + notification, ok := s.takePending() + if !ok { + break + } + + for _, subscriber := range notification.subscribers { + subscriber(notification.status) + } + } + } +} + +func (s *h264ModeStore) takePending() (h264ModeStatusNotification, bool) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if len(s.pending) == 0 { + return h264ModeStatusNotification{}, false + } + + notification := s.pending[0] + s.pending = s.pending[1:] + return notification, true +} + +func isH264Mode(mode string) bool { + return mode == H264ModeDirect || mode == H264ModeWebRTC +} diff --git a/server/service/stream/webrtc/h264.go b/server/service/stream/webrtc/h264.go index 6be33c9..4acbb8f 100644 --- a/server/service/stream/webrtc/h264.go +++ b/server/service/stream/webrtc/h264.go @@ -74,12 +74,9 @@ func Connect(c *gin.Context) { return } - manager := getManager() - manager.AddClient(wsConn, client) - defer manager.RemoveClient(wsConn) - // handle signaling signalingHandler := NewSignalingHandler(client) + defer signalingHandler.Close() signalingHandler.RegisterCallbacks() if err := sendICEServers(client, iceServers); err != nil { log.Errorf("failed to send ICE servers: %s", err) diff --git a/server/service/stream/webrtc/manager.go b/server/service/stream/webrtc/manager.go index ac625ad..22bbeb1 100644 --- a/server/service/stream/webrtc/manager.go +++ b/server/service/stream/webrtc/manager.go @@ -4,7 +4,6 @@ import ( "NanoKVM-Server/common" "NanoKVM-Server/service/stream" "NanoKVM-Server/service/vm" - "sync/atomic" "time" "github.com/gorilla/websocket" @@ -15,7 +14,7 @@ import ( func NewWebRTCManager() *WebRTCManager { m := &WebRTCManager{ clients: make(map[*websocket.Conn]*Client), - videoSending: 0, + videoSending: false, } m.updateClientSnapshotLocked() @@ -72,15 +71,31 @@ func (m *WebRTCManager) getClients() []*Client { } func (m *WebRTCManager) StartVideoStream() { - if atomic.CompareAndSwapInt32(&m.videoSending, 0, 1) { - go m.sendVideoStream() - log.Debugf("start sending h264 stream") + m.mutex.Lock() + if m.videoSending || len(m.clients) == 0 { + m.mutex.Unlock() + return } + m.videoSending = true + m.mutex.Unlock() + + go m.sendVideoStream() + log.Debugf("start sending h264 stream") +} + +func (m *WebRTCManager) stopVideoStreamIfIdle() bool { + m.mutex.Lock() + defer m.mutex.Unlock() + + if len(m.clients) > 0 { + return false + } + + m.videoSending = false + return true } func (m *WebRTCManager) sendVideoStream() { - defer atomic.StoreInt32(&m.videoSending, 0) - screen := common.GetScreen() common.CheckScreen() fps := screen.FPS @@ -94,8 +109,12 @@ func (m *WebRTCManager) sendVideoStream() { for range ticker.C { clients := m.getClients() if len(clients) == 0 { - log.Debugf("stop sending h264 stream") - return + if m.stopVideoStreamIfIdle() { + log.Debugf("stop sending h264 stream") + return + } + + continue } data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate) diff --git a/server/service/stream/webrtc/signaling.go b/server/service/stream/webrtc/signaling.go index 7423989..6db7abd 100644 --- a/server/service/stream/webrtc/signaling.go +++ b/server/service/stream/webrtc/signaling.go @@ -1,6 +1,7 @@ package webrtc import ( + "NanoKVM-Server/service/stream" "encoding/json" "errors" @@ -33,18 +34,85 @@ func (s *SignalingHandler) RegisterCallbacks() { } }) - manager := getManager() - // video connection state change s.client.video.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { - if state == webrtc.ICEConnectionStateConnected { - manager.StartVideoStream() - } + s.updateVideoStreamState(state) log.Debugf("video connection state changed to %s", state.String()) }) } +func (s *SignalingHandler) Close() { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.closed { + return + } + + s.closed = true + s.unregisterH264ModeLocked() + if s.client != nil && s.client.WsConn() != nil { + getManager().RemoveClient(s.client.WsConn()) + } +} + +func (s *SignalingHandler) updateVideoStreamState(state webrtc.ICEConnectionState) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.closed { + return + } + + manager := getManager() + if s.updateH264ModeLocked(state) { + manager.AddClient(s.client.WsConn(), s.client) + manager.StartVideoStream() + return + } + + manager.RemoveClient(s.client.WsConn()) +} + +func (s *SignalingHandler) updateH264Mode(state webrtc.ICEConnectionState) bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.closed { + return false + } + + return s.updateH264ModeLocked(state) +} + +func (s *SignalingHandler) updateH264ModeLocked(state webrtc.ICEConnectionState) bool { + if state == webrtc.ICEConnectionStateConnected || state == webrtc.ICEConnectionStateCompleted { + s.registerH264ModeLocked() + return true + } + + s.unregisterH264ModeLocked() + return false +} + +func (s *SignalingHandler) registerH264ModeLocked() { + if s.unregisterMode != nil { + return + } + + s.unregisterMode = stream.RegisterH264Mode(stream.H264ModeWebRTC) +} + +func (s *SignalingHandler) unregisterH264ModeLocked() { + unregisterMode := s.unregisterMode + s.unregisterMode = nil + + if unregisterMode != nil { + unregisterMode() + } +} + // HandleMessage handle the received message func (s *SignalingHandler) HandleMessage(message *Message) error { switch message.Event { diff --git a/server/service/stream/webrtc/types.go b/server/service/stream/webrtc/types.go index 5ee1280..424f892 100644 --- a/server/service/stream/webrtc/types.go +++ b/server/service/stream/webrtc/types.go @@ -12,7 +12,7 @@ import ( type WebRTCManager struct { clients map[*websocket.Conn]*Client clientSnapshot atomic.Pointer[[]*Client] - videoSending int32 + videoSending bool mutex sync.Mutex viewerVersion uint64 } @@ -29,7 +29,10 @@ func (c *Client) WsConn() *websocket.Conn { } type SignalingHandler struct { - client *Client + client *Client + mutex sync.Mutex + unregisterMode func() + closed bool } type Track struct { diff --git a/server/service/ws/h264_mode.go b/server/service/ws/h264_mode.go new file mode 100644 index 0000000..ce4de57 --- /dev/null +++ b/server/service/ws/h264_mode.go @@ -0,0 +1,93 @@ +package ws + +import ( + "encoding/json" + "sync" + + "NanoKVM-Server/service/stream" + log "github.com/sirupsen/logrus" +) + +var h264ModeStatusBroadcasterInstance = newH264ModeStatusBroadcaster(broadcastH264ModeStatus) + +func init() { + go h264ModeStatusBroadcasterInstance.Run() + + stream.SubscribeH264ModeStatus(func(status stream.H264ModeStatus) { + h264ModeStatusBroadcasterInstance.Enqueue(status) + }) +} + +type h264ModeStatusBroadcaster struct { + mutex sync.Mutex + pending *stream.H264ModeStatus + notify chan struct{} + broadcast func(stream.H264ModeStatus) +} + +func newH264ModeStatusBroadcaster(broadcast func(stream.H264ModeStatus)) *h264ModeStatusBroadcaster { + return &h264ModeStatusBroadcaster{ + notify: make(chan struct{}, 1), + broadcast: broadcast, + } +} + +func (b *h264ModeStatusBroadcaster) Enqueue(status stream.H264ModeStatus) { + b.mutex.Lock() + b.pending = &status + b.mutex.Unlock() + + select { + case b.notify <- struct{}{}: + default: + } +} + +func (b *h264ModeStatusBroadcaster) Run() { + for range b.notify { + for { + status, ok := b.takePending() + if !ok { + break + } + + b.broadcast(status) + } + } +} + +func (b *h264ModeStatusBroadcaster) takePending() (stream.H264ModeStatus, bool) { + b.mutex.Lock() + defer b.mutex.Unlock() + + if b.pending == nil { + return stream.H264ModeStatus{}, false + } + + status := *b.pending + b.pending = nil + return status, true +} + +func sendH264ModeStatusSnapshot(client *Client) { + if err := sendH264ModeStatus(client, stream.CurrentH264ModeStatus()); err != nil { + log.Errorf("failed to send h264 mode status snapshot: %s", err) + } +} + +func broadcastH264ModeStatus(status stream.H264ModeStatus) { + for _, client := range GetManager().GetClients() { + if err := sendH264ModeStatus(client, status); err != nil { + log.Errorf("failed to send h264 mode status: %s", err) + } + } +} + +func sendH264ModeStatus(client *Client, status stream.H264ModeStatus) error { + payload, err := json.Marshal(status) + if err != nil { + return err + } + + return client.Write(stream.H264ModeStatusEvent, string(payload)) +} diff --git a/server/service/ws/service.go b/server/service/ws/service.go index 4f3fbc1..659efd8 100644 --- a/server/service/ws/service.go +++ b/server/service/ws/service.go @@ -38,6 +38,7 @@ func (s *Service) Connect(c *gin.Context) { defer manager.RemoveClient(ws) sendCaptureStatusSnapshot(client) + sendH264ModeStatusSnapshot(client) client.Start() } diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index b0caf49..237bbae 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -78,6 +78,11 @@ const ca = { frameDetectTip: "Calcula la diferència entre fotogrames. S'atura la transmissió si no hi ha canvis a la pantalla de l'amfitrió remot.", resetHdmi: 'Restablir HDMI', + mixedH264: { + title: 'Conflicte de flux H.264', + description: + "S'estan utilitzant H.264 Direct i H.264 WebRTC alhora. Això pot provocar esquinçament de pantalla o vídeo corrupte. Utilitzeu només un mode H.264." + }, captureStatus: { hdmiError: 'Error a la pantalla HDMI', unsupportedResolution: 'La resolució actual no és compatible', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index dd93a02..9aa8af8 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -79,6 +79,11 @@ const cz = { frameDetectTip: 'Vypočítá rozdíl mezi snímky. Přenos video streamu se zastaví, pokud nejsou detekovány změny na obrazovce vzdáleného hostitele.', resetHdmi: 'Resetovat HDMI', + mixedH264: { + title: 'Konflikt streamu H.264', + description: + 'H.264 Direct a H.264 WebRTC se používají současně. To může způsobit trhání obrazu nebo poškozené video. Používejte pouze jeden režim H.264.' + }, captureStatus: { hdmiError: 'Chyba obrazu HDMI', unsupportedResolution: 'Aktuální rozlišení není podporováno', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 288a07e..4a49b12 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -78,6 +78,11 @@ const da = { frameDetectTip: 'Beregner forskellen mellem hver frame. Stopper med at sende et video stream hvis der ikke registreres ændringer på fjerncomputerens skærm.', resetHdmi: 'Nulstil HDMI', + mixedH264: { + title: 'H.264-streamkonflikt', + description: + 'H.264 Direct og H.264 WebRTC bruges samtidigt. Dette kan forårsage skærmrivning eller beskadiget video. Brug kun én H.264-tilstand.' + }, captureStatus: { hdmiError: 'Fejl i HDMI-billedet', unsupportedResolution: 'Den aktuelle opløsning understøttes ikke', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index f968f3c..a3b5ef2 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -81,6 +81,11 @@ const de = { frameDetectTip: 'Berechnet den Unterschied zwischen den Einzelbildern. Beendet die Liveübertragung des Videostreams wenn keine Änderungen auf dem Bildschirm des Hosts festgestellt werden kann.', resetHdmi: 'HDMI zurücksetzen', + mixedH264: { + title: 'H.264-Streamkonflikt', + description: + 'H.264 Direct und H.264 WebRTC werden gleichzeitig verwendet. Dies kann zu Bildschirm-Tearing oder beschädigtem Video führen. Bitte verwenden Sie nur einen H.264-Modus.' + }, captureStatus: { hdmiError: 'HDMI-Bildschirmfehler', unsupportedResolution: 'Die aktuelle Auflösung wird nicht unterstützt', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 7295faa..d4d2d4d 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -77,6 +77,11 @@ const en = { frameDetectTip: "Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.", resetHdmi: 'Reset HDMI', + mixedH264: { + title: 'H.264 stream conflict', + description: + 'H.264 Direct and H.264 WebRTC are being used at the same time. This may cause screen tearing or corrupted video. Please use only one H.264 mode.' + }, captureStatus: { hdmiError: 'HDMI screen error', unsupportedResolution: 'Current resolution is not supported', diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index e13188e..bc11fa8 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -79,6 +79,11 @@ const es = { frameDetectTip: 'Calcula la diferencia entre fotogramas. Para de transmitir vídeo cuando no se detectan cambios en la pantalla del host remoto.', resetHdmi: 'Reiniciar HDMI', + mixedH264: { + title: 'Conflicto de flujo H.264', + description: + 'H.264 Direct y H.264 WebRTC se están utilizando al mismo tiempo. Esto puede causar tearing de pantalla o vídeo corrupto. Utilice solo un modo H.264.' + }, captureStatus: { hdmiError: 'Error de imagen HDMI', unsupportedResolution: 'La resolución actual no es compatible', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 455f5bd..af1f883 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -81,6 +81,11 @@ const fr = { frameDetectTip: "Calcule la différence entre les images. Arrête la transmission du flux vidéo lorsqu'aucun changement n'est détecté sur l'écran de l'hôte distant", resetHdmi: 'Réinitialiser le HDMI', + mixedH264: { + title: 'Conflit de flux H.264', + description: + 'Les modes H.264 Direct et H.264 WebRTC sont utilisés simultanément. Cela peut provoquer des déchirures d’écran ou une vidéo corrompue. Veuillez n’utiliser qu’un seul mode H.264.' + }, captureStatus: { hdmiError: 'Erreur d’image HDMI', unsupportedResolution: 'La résolution actuelle n’est pas prise en charge', diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 6a392ed..054e851 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -80,6 +80,11 @@ const hu = { frameDetectTip: 'Elemzi a képkockák közötti különbségeket. A videó stream küldése leáll, ha a távoli gép képernyőjén nem történik változás.', resetHdmi: 'HDMI visszaállítása', + mixedH264: { + title: 'H.264 adatfolyam-ütközés', + description: + 'Az H.264 Direct és az H.264 WebRTC egyszerre van használatban. Ez képtörést vagy sérült videót okozhat. Csak egy H.264 módot használjon.' + }, captureStatus: { hdmiError: 'HDMI-képernyőhiba', unsupportedResolution: 'A jelenlegi felbontás nem támogatott', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 7aff35d..4318225 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -78,6 +78,11 @@ const id = { frameDetectTip: 'Hitung selisih antar frame. Hentikan transmisi aliran video saat tidak ada perubahan yang terdeteksi di layar host jarak jauh.', resetHdmi: 'Atur ulang HDMI', + mixedH264: { + title: 'Konflik aliran H.264', + description: + 'H.264 Direct dan H.264 WebRTC sedang digunakan secara bersamaan. Hal ini dapat menyebabkan layar robek atau video rusak. Harap gunakan hanya satu mode H.264.' + }, captureStatus: { hdmiError: 'Kesalahan layar HDMI', unsupportedResolution: 'Resolusi saat ini tidak didukung', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index f0e2e19..939f787 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -80,6 +80,11 @@ const it = { frameDetectTip: 'Calcola la differenza tra i frame. Interrompe la trasmissione del flusso video quando non vengono rilevate modifiche sullo schermo del dispositivo remoto.', resetHdmi: 'Reimposta HDMI', + mixedH264: { + title: 'Conflitto del flusso H.264', + description: + 'I flussi H.264 Direct e H.264 WebRTC sono utilizzati contemporaneamente. Ciò può causare tearing dello schermo o video danneggiato. Utilizzare una sola modalità H.264.' + }, captureStatus: { hdmiError: 'Errore schermata HDMI', unsupportedResolution: 'La risoluzione attuale non è supportata', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index fec9b42..21b757e 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -78,6 +78,11 @@ const ja = { frameDetectTip: 'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します', resetHdmi: 'HDMI をリセット', + mixedH264: { + title: 'H.264 ストリームの競合', + description: + 'H.264 Direct と H.264 WebRTC が同時に使用されています。画面のティアリングや映像の破損が発生する可能性があります。H.264 モードは 1 つだけ使用してください。' + }, captureStatus: { hdmiError: 'HDMI 画面エラー', unsupportedResolution: '現在の解像度はサポートされていません', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 730d89a..565f4e5 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -77,6 +77,11 @@ const ko = { frameDetectTip: '프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.', resetHdmi: 'HDMI 초기화', + mixedH264: { + title: 'H.264 스트림 충돌', + description: + 'H.264 Direct와 H.264 WebRTC가 동시에 사용되고 있습니다. 화면 찢어짐이나 손상된 영상이 발생할 수 있습니다. H.264 모드는 하나만 사용하세요.' + }, captureStatus: { hdmiError: 'HDMI 화면 오류', unsupportedResolution: '현재 해상도는 지원되지 않습니다', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 7fe194d..110a77e 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -79,6 +79,11 @@ const nb = { frameDetectTip: 'Kalkuler forskjellen mellom bilder. Stopper overføring av video når det ikke oppdages forskjell på den eksterne vertens skjerm.', resetHdmi: 'Tilbakestill HDMI', + mixedH264: { + title: 'H.264-strømmekonflikt', + description: + 'H.264 Direct og H.264 WebRTC brukes samtidig. Dette kan føre til skjermriving eller ødelagt video. Bruk bare én H.264-modus.' + }, captureStatus: { hdmiError: 'HDMI-skjermfeil', unsupportedResolution: 'Gjeldende oppløsning støttes ikke', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index eb020c1..a8f6436 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -80,6 +80,11 @@ const nl = { frameDetectTip: 'Berekent het verschil tussen frames. Stopt met het verzenden van de videostream wanneer er geen veranderingen worden gedetecteerd op het scherm van de externe host.', resetHdmi: 'Reset HDMI', + mixedH264: { + title: 'H.264-streamconflict', + description: + 'H.264 Direct en H.264 WebRTC worden tegelijkertijd gebruikt. Dit kan tearing of beschadigde video veroorzaken. Gebruik slechts één H.264-modus.' + }, captureStatus: { hdmiError: 'HDMI-schermfout', unsupportedResolution: 'De huidige resolutie wordt niet ondersteund', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index 0805e09..9ba9871 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -79,6 +79,11 @@ const pl = { frameDetectTip: 'Obliczanie różnicy między klatkami. Zatrzymaj transmisję strumienia wideo, gdy na ekranie zdalnego hosta nie zostaną wykryte żadne zmiany.', resetHdmi: 'Resetuj HDMI', + mixedH264: { + title: 'Konflikt strumieni H.264', + description: + 'Strumienie H.264 Direct i H.264 WebRTC są używane jednocześnie. Może to powodować rozrywanie obrazu lub uszkodzenie wideo. Używaj tylko jednego trybu H.264.' + }, captureStatus: { hdmiError: 'Błąd obrazu HDMI', unsupportedResolution: 'Bieżąca rozdzielczość nie jest obsługiwana', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index a62e751..1af720a 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -78,6 +78,11 @@ const pt_br = { frameDetectTip: 'Calcular a diferença entre os quadros. Parar a transmissão de vídeo quando nenhuma alteração for detectada na tela do host remoto.', resetHdmi: 'Redefinir HDMI', + mixedH264: { + title: 'Conflito de transmissão H.264', + description: + 'H.264 Direct e H.264 WebRTC estão sendo usados ao mesmo tempo. Isso pode causar rasgos na tela ou vídeo corrompido. Use apenas um modo H.264.' + }, captureStatus: { hdmiError: 'Erro na imagem HDMI', unsupportedResolution: 'A resolução atual não é compatível', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 16b4d89..8bf1b96 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -78,6 +78,11 @@ const ru = { frameDetectTip: 'Вычисляет разницу между кадрами и прекращает передачу видеопотока, если на экране удаленного узла не обнаружено никаких изменений.', resetHdmi: 'Перезагрузить HDMI подсистему', + mixedH264: { + title: 'Конфликт потоков H.264', + description: + 'H.264 Direct и H.264 WebRTC используются одновременно. Это может привести к разрывам изображения или повреждению видео. Используйте только один режим H.264.' + }, captureStatus: { hdmiError: 'Ошибка изображения HDMI', unsupportedResolution: 'Текущее разрешение не поддерживается', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index d717fa2..7e29b9e 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -76,6 +76,11 @@ const se = { frameDetectTip: 'Beräkna skillnaden mellan ramar. Sluta skicka videoström när inga förändringar upptäcks på fjärrvärdens skärm.', resetHdmi: 'Återställ HDMI', + mixedH264: { + title: 'H.264-strömningskonflikt', + description: + 'H.264 Direct och H.264 WebRTC används samtidigt. Detta kan orsaka skärmrivningar eller skadad video. Använd endast ett H.264-läge.' + }, captureStatus: { hdmiError: 'HDMI-skärmfel', unsupportedResolution: 'Den aktuella upplösningen stöds inte', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index 22c0984..de3d5b6 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -76,6 +76,11 @@ const th = { frameDetectTip: 'ระบบจะคำนวณความแตกต่างระหว่างเฟรม และหยุดส่งสตรีมวิดีโอเมื่อไม่พบการเปลี่ยนแปลงบนหน้าจอของคอมพิวเตอร์ต้นทาง', resetHdmi: 'รีเช็ท HDMI', + mixedH264: { + title: 'สตรีม H.264 ขัดแย้งกัน', + description: + 'กำลังใช้งาน H.264 Direct และ H.264 WebRTC พร้อมกัน ซึ่งอาจทำให้ภาพฉีกขาดหรือวิดีโอเสียหาย โปรดใช้โหมด H.264 เพียงโหมดเดียว' + }, captureStatus: { hdmiError: 'ข้อผิดพลาดหน้าจอ HDMI', unsupportedResolution: 'ไม่รองรับความละเอียดปัจจุบัน', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index e520467..d2985ef 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -79,6 +79,11 @@ const tr = { frameDetectTip: 'Gönderilen kareler arasındaki farkı hesaplar. Uzak ana bilgisayardan gönderilen yayında bir değişiklik yoksa görüntü yayınını durdurur.', resetHdmi: 'HDMI sıfırla', + mixedH264: { + title: 'H.264 akış çakışması', + description: + 'H.264 Direct ve H.264 WebRTC aynı anda kullanılıyor. Bu, ekran yırtılmasına veya bozuk videoya neden olabilir. Lütfen yalnızca bir H.264 modu kullanın.' + }, captureStatus: { hdmiError: 'HDMI ekran hatası', unsupportedResolution: 'Geçerli çözünürlük desteklenmiyor', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index e36b050..7a3ac1a 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -79,6 +79,11 @@ const uk = { frameDetectTip: 'Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін.', resetHdmi: 'Перезавантажити HDMI підсистему', + mixedH264: { + title: 'Конфлікт потоків H.264', + description: + 'H.264 Direct і H.264 WebRTC використовуються одночасно. Це може спричинити розриви зображення або пошкодження відео. Використовуйте лише один режим H.264.' + }, captureStatus: { hdmiError: 'Помилка зображення HDMI', unsupportedResolution: 'Поточна роздільна здатність не підтримується', diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index e0462e8..eced068 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -78,6 +78,11 @@ const vi = { frameDetectTip: 'Tính toán sự khác biệt giữa các khung hình. Dừng truyền video khi không có thay đổi trên màn hình máy chủ từ xa.', resetHdmi: 'Đặt lại HDMI', + mixedH264: { + title: 'Xung đột luồng H.264', + description: + 'H.264 Direct và H.264 WebRTC đang được sử dụng đồng thời. Điều này có thể gây xé hình hoặc video bị hỏng. Vui lòng chỉ sử dụng một chế độ H.264.' + }, captureStatus: { hdmiError: 'Lỗi màn hình HDMI', unsupportedResolution: 'Độ phân giải hiện tại không được hỗ trợ', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 7fb15b4..fb23103 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -74,6 +74,11 @@ const zh = { frameDetect: '帧差检测', frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流', resetHdmi: '重置 HDMI', + mixedH264: { + title: 'H.264 视频流冲突', + description: + '检测到 H.264 Direct 和 H.264 WebRTC 同时使用,可能导致画面撕裂或花屏。请只保留一种 H.264 模式。' + }, captureStatus: { hdmiError: 'HDMI 画面异常', unsupportedResolution: '当前分辨率不支持', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index d2780d4..7b10773 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -74,6 +74,11 @@ const zh_tw = { frameDetect: '影格檢測', frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。', resetHdmi: '重置 HDMI', + mixedH264: { + title: 'H.264 串流衝突', + description: + '偵測到 H.264 Direct 和 H.264 WebRTC 同時使用,可能導致畫面撕裂或影片損壞。請只保留一種 H.264 模式。' + }, captureStatus: { hdmiError: 'HDMI 畫面異常', unsupportedResolution: '目前解析度不支援', diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index 5ed1058..a06337a 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -14,7 +14,7 @@ import { CaptureStatusOverlay, useCaptureStatus } from './capture-status'; import { Keyboard } from './keyboard'; import { Menu } from './menu'; import { Mouse } from './mouse'; -import { Notification } from './notification.tsx'; +import { H264ModeNotification, Notification } from './notification.tsx'; import { Sidebar as PicoclawSidebar } from './picoclaw'; import { ActionOverlay } from './picoclaw/action-overlay.tsx'; import { Screen } from './screen'; @@ -70,6 +70,7 @@ export const Desktop = () => { {isBigScreen && } + {videoMode && resolution && (
diff --git a/web/src/pages/desktop/notification.tsx b/web/src/pages/desktop/notification.tsx index 4c06eef..640fc2b 100644 --- a/web/src/pages/desktop/notification.tsx +++ b/web/src/pages/desktop/notification.tsx @@ -1,10 +1,20 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { Button, notification } from 'antd'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { isPasswordUpdated } from '@/api/auth.ts'; import { getSkipModifyPassword, setSkipModifyPassword } from '@/lib/localstorage.ts'; +import { client } from '@/lib/websocket.ts'; + +const H264_MODE_STATUS_EVENT = 'h264-mode-status'; +const MIXED_H264_NOTIFICATION_KEY = 'mixed_h264_modes'; + +type H264ModeStatus = { + generation: string; + revision: number; + mixed: boolean; +}; export const Notification = () => { const { t } = useTranslation(); @@ -17,31 +27,85 @@ export const Notification = () => { isPasswordUpdated().then((rsp) => { if (rsp.code === 0 && !rsp.data.isUpdated) { - openNotification(); + api.warning({ + key: 'no_change_password', + message: t('auth.changePassword'), + description: t('auth.changePasswordDesc'), + placement: 'topRight', + btn: ( + + ), + duration: null, + onClose: () => setSkipModifyPassword(true) + }); } }); - }, []); - - function openNotification() { - api.warning({ - key: 'no_change_password', - message: t('auth.changePassword'), - description: t('auth.changePasswordDesc'), - placement: 'topRight', - btn: ( - - ), - duration: null, - onClose: () => setSkipModifyPassword(true) - }); - } - - function changePassword() { - api.destroy(); - navigate('/auth/password'); - } + }, [api, navigate, t]); return <>{contextHolder}; }; + +export const H264ModeNotification = () => { + const { t } = useTranslation(); + const [api, contextHolder] = notification.useNotification(); + const latestStatusRef = useRef(null); + + useEffect(() => { + return client.on(H264_MODE_STATUS_EVENT, (message) => { + const status = parseH264ModeStatus(message.data); + if (!status) return; + + const latestStatus = latestStatusRef.current; + if (latestStatus && status.generation === latestStatus.generation && status.revision < latestStatus.revision) { + return; + } + latestStatusRef.current = status; + + if (status.mixed) { + api.warning({ + key: MIXED_H264_NOTIFICATION_KEY, + message: t('screen.mixedH264.title'), + description: t('screen.mixedH264.description'), + placement: 'topRight', + duration: null + }); + } else { + api.destroy(MIXED_H264_NOTIFICATION_KEY); + } + }); + }, [api, t]); + + return <>{contextHolder}; +}; + +function parseH264ModeStatus(data: unknown): H264ModeStatus | null { + if (typeof data !== 'string') return null; + + try { + const envelope = JSON.parse(data) as { data?: unknown }; + if (typeof envelope.data !== 'string') return null; + + const status = JSON.parse(envelope.data) as Partial; + if ( + typeof status.generation !== 'string' || + typeof status.revision !== 'number' || + !Number.isSafeInteger(status.revision) || + status.revision < 0 || + typeof status.mixed !== 'boolean' + ) { + return null; + } + + return { generation: status.generation, revision: status.revision, mixed: status.mixed }; + } catch { + return null; + } +}