mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat: show HDMI capture status on desktop screen
Track capture results for each stream mode, broadcast status updates over WebSocket, and display localized warning/error overlays in the desktop view.
This commit is contained in:
167
server/service/stream/capture_status.go
Normal file
167
server/service/stream/capture_status.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CaptureStatusEvent = "capture-status"
|
||||
CaptureModeDirect = "direct"
|
||||
CaptureModeH264 = "h264"
|
||||
CaptureModeMJPEG = "mjpeg"
|
||||
CaptureSeverityError = "error"
|
||||
CaptureSeverityWarning = "warning"
|
||||
)
|
||||
|
||||
type CaptureStatus struct {
|
||||
Ok bool `json:"ok"`
|
||||
Result int `json:"result"`
|
||||
Message string `json:"message"`
|
||||
Mode string `json:"mode"`
|
||||
Severity string `json:"severity"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CaptureStatusSubscriber func(CaptureStatus)
|
||||
|
||||
var defaultStore = newStore(time.Now)
|
||||
|
||||
func SubscribeCaptureStatus(subscriber CaptureStatusSubscriber) func() {
|
||||
return defaultStore.Subscribe(subscriber)
|
||||
}
|
||||
|
||||
func UpdateCaptureStatus(mode string, result int) {
|
||||
defaultStore.UpdateCaptureStatus(mode, result)
|
||||
}
|
||||
|
||||
func LatestCaptureStatuses() []CaptureStatus {
|
||||
return defaultStore.LatestCaptureStatuses()
|
||||
}
|
||||
|
||||
type store struct {
|
||||
mutex sync.Mutex
|
||||
latestByMode map[string]CaptureStatus
|
||||
now func() time.Time
|
||||
subscribers map[int]CaptureStatusSubscriber
|
||||
nextSubscriberID int
|
||||
}
|
||||
|
||||
func newStore(now func() time.Time) *store {
|
||||
return &store{
|
||||
now: now,
|
||||
latestByMode: make(map[string]CaptureStatus),
|
||||
subscribers: make(map[int]CaptureStatusSubscriber),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *store) Subscribe(subscriber CaptureStatusSubscriber) func() {
|
||||
s.mutex.Lock()
|
||||
id := s.nextSubscriberID
|
||||
s.nextSubscriberID++
|
||||
s.subscribers[id] = subscriber
|
||||
s.mutex.Unlock()
|
||||
|
||||
return func() {
|
||||
s.mutex.Lock()
|
||||
delete(s.subscribers, id)
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *store) UpdateCaptureStatus(mode string, result int) {
|
||||
next := newCaptureStatus(mode, result, s.now())
|
||||
subscribers := s.update(next)
|
||||
for _, subscriber := range subscribers {
|
||||
subscriber(next)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *store) update(next CaptureStatus) []CaptureStatusSubscriber {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
last, ok := s.latestByMode[next.Mode]
|
||||
if ok && samePublicStatus(last, next) {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.latestByMode[next.Mode] = next
|
||||
|
||||
subscribers := make([]CaptureStatusSubscriber, 0, len(s.subscribers))
|
||||
for _, subscriber := range s.subscribers {
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
return subscribers
|
||||
}
|
||||
|
||||
func (s *store) LatestCaptureStatus(mode string) (CaptureStatus, bool) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
status, ok := s.latestByMode[mode]
|
||||
return status, ok
|
||||
}
|
||||
|
||||
func (s *store) LatestCaptureStatuses() []CaptureStatus {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
modes := make([]string, 0, len(s.latestByMode))
|
||||
for mode := range s.latestByMode {
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
sort.Strings(modes)
|
||||
|
||||
statuses := make([]CaptureStatus, 0, len(modes))
|
||||
for _, mode := range modes {
|
||||
statuses = append(statuses, s.latestByMode[mode])
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
func samePublicStatus(a CaptureStatus, b CaptureStatus) bool {
|
||||
if a.Ok && b.Ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return a.Ok == b.Ok && a.Result == b.Result && a.Mode == b.Mode
|
||||
}
|
||||
|
||||
func newCaptureStatus(mode string, result int, updatedAt time.Time) CaptureStatus {
|
||||
message, severity := captureResultMessage(result)
|
||||
return CaptureStatus{
|
||||
Ok: result >= 0,
|
||||
Result: result,
|
||||
Message: message,
|
||||
Mode: mode,
|
||||
Severity: severity,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func captureResultMessage(result int) (string, string) {
|
||||
switch result {
|
||||
case -7:
|
||||
return "HDMI input resolution error", CaptureSeverityError
|
||||
case -6:
|
||||
return "Unsupported HDMI resolution", CaptureSeverityError
|
||||
case -5:
|
||||
return "Retrieving image", CaptureSeverityWarning
|
||||
case -4:
|
||||
return "Changing image resolution", CaptureSeverityWarning
|
||||
case -3:
|
||||
return "Image buffer full", CaptureSeverityError
|
||||
case -2:
|
||||
return "Encoder error", CaptureSeverityError
|
||||
case -1:
|
||||
return "No image captured", CaptureSeverityError
|
||||
default:
|
||||
if result < 0 {
|
||||
return "Capture failed", CaptureSeverityError
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (s *Streamer) run() {
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeDirect, result)
|
||||
if result < 0 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Streamer) run() {
|
||||
}
|
||||
|
||||
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
|
||||
if result < 0 || result == 5 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ func (m *WebRTCManager) sendVideoStream() {
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeH264, result)
|
||||
if result < 0 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
111
server/service/ws/capture_status.go
Normal file
111
server/service/ws/capture_status.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/service/stream"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var captureBroadcaster = newCaptureStatusBroadcaster(broadcastCaptureStatus)
|
||||
|
||||
func init() {
|
||||
go captureBroadcaster.Run()
|
||||
|
||||
stream.SubscribeCaptureStatus(func(status stream.CaptureStatus) {
|
||||
captureBroadcaster.Enqueue(status)
|
||||
})
|
||||
}
|
||||
|
||||
type captureStatusBroadcaster struct {
|
||||
mutex sync.Mutex
|
||||
pending map[string]stream.CaptureStatus
|
||||
notify chan struct{}
|
||||
broadcast func(stream.CaptureStatus)
|
||||
}
|
||||
|
||||
func newCaptureStatusBroadcaster(broadcast func(stream.CaptureStatus)) *captureStatusBroadcaster {
|
||||
return &captureStatusBroadcaster{
|
||||
pending: make(map[string]stream.CaptureStatus),
|
||||
notify: make(chan struct{}, 1),
|
||||
broadcast: broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *captureStatusBroadcaster) Enqueue(status stream.CaptureStatus) {
|
||||
b.mutex.Lock()
|
||||
b.pending[status.Mode] = status
|
||||
b.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case b.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (b *captureStatusBroadcaster) Run() {
|
||||
for range b.notify {
|
||||
for {
|
||||
statuses := b.takePending()
|
||||
if len(statuses) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, status := range statuses {
|
||||
b.broadcast(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *captureStatusBroadcaster) takePending() []stream.CaptureStatus {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
if len(b.pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
modes := make([]string, 0, len(b.pending))
|
||||
for mode := range b.pending {
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
sort.Strings(modes)
|
||||
|
||||
statuses := make([]stream.CaptureStatus, 0, len(modes))
|
||||
for _, mode := range modes {
|
||||
statuses = append(statuses, b.pending[mode])
|
||||
delete(b.pending, mode)
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
func sendCaptureStatusSnapshot(client *Client) {
|
||||
for _, status := range stream.LatestCaptureStatuses() {
|
||||
if err := sendCaptureStatus(client, status); err != nil {
|
||||
log.Errorf("failed to send capture status snapshot: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastCaptureStatus(status stream.CaptureStatus) {
|
||||
for _, client := range GetManager().GetClients() {
|
||||
if err := sendCaptureStatus(client, status); err != nil {
|
||||
log.Errorf("failed to send capture status: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendCaptureStatus(client *Client, status stream.CaptureStatus) error {
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal capture status: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Write(stream.CaptureStatusEvent, string(payload))
|
||||
}
|
||||
@@ -37,5 +37,7 @@ func (s *Service) Connect(c *gin.Context) {
|
||||
manager.AddClient(ws, client)
|
||||
defer manager.RemoveClient(ws)
|
||||
|
||||
sendCaptureStatusSnapshot(client)
|
||||
|
||||
client.Start()
|
||||
}
|
||||
|
||||
@@ -77,7 +77,17 @@ const ca = {
|
||||
frameDetect: 'Detecció de fotogrames',
|
||||
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'
|
||||
resetHdmi: 'Restablir HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Error a la pantalla HDMI',
|
||||
unsupportedResolution: 'La resolució actual no és compatible',
|
||||
retrieving: "S'està obtenint la pantalla...",
|
||||
changingResolution: "S'està canviant la resolució...",
|
||||
updateFailed: 'La pantalla no es pot actualitzar ara',
|
||||
videoError: 'Error de visualització de vídeo',
|
||||
noHdmi: "No s'ha detectat cap senyal HDMI",
|
||||
unavailable: 'La pantalla no es pot mostrar ara'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Teclat',
|
||||
@@ -532,6 +542,7 @@ const ca = {
|
||||
success: 'PicoClaw instal·lat correctament',
|
||||
failed: "No s'ha pogut instal·lar PicoClaw",
|
||||
uninstalling: "S'està desinstal·lant el temps d'execució...",
|
||||
uninstaling: "S'està desinstal·lant el temps d'execució...",
|
||||
uninstalled: "El temps d'execució s'ha desinstal·lat correctament.",
|
||||
uninstallFailed: 'La desinstal·lació ha fallat.',
|
||||
requiredTitle: 'PicoClaw no està instal·lat',
|
||||
|
||||
@@ -78,7 +78,17 @@ const cz = {
|
||||
frameDetect: 'Detekce snímků',
|
||||
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'
|
||||
resetHdmi: 'Resetovat HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Chyba obrazu HDMI',
|
||||
unsupportedResolution: 'Aktuální rozlišení není podporováno',
|
||||
retrieving: 'Načítá se obraz...',
|
||||
changingResolution: 'Přepíná se rozlišení...',
|
||||
updateFailed: 'Obraz se teď nemůže aktualizovat',
|
||||
videoError: 'Chyba zobrazení videa',
|
||||
noHdmi: 'Nebyl zjištěn signál HDMI',
|
||||
unavailable: 'Obraz teď nelze zobrazit'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Klávesnice',
|
||||
|
||||
@@ -77,7 +77,17 @@ const da = {
|
||||
frameDetect: 'Beregn frames',
|
||||
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'
|
||||
resetHdmi: 'Nulstil HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Fejl i HDMI-billedet',
|
||||
unsupportedResolution: 'Den aktuelle opløsning understøttes ikke',
|
||||
retrieving: 'Henter skærmbillede...',
|
||||
changingResolution: 'Skifter opløsning...',
|
||||
updateFailed: 'Skærmbilledet kan ikke opdateres lige nu',
|
||||
videoError: 'Fejl i videovisning',
|
||||
noHdmi: 'Intet HDMI-signal registreret',
|
||||
unavailable: 'Skærmbilledet kan ikke vises lige nu'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Tastatur',
|
||||
|
||||
@@ -80,7 +80,17 @@ const de = {
|
||||
frameDetect: 'Bilderkennung',
|
||||
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'
|
||||
resetHdmi: 'HDMI zurücksetzen',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI-Bildschirmfehler',
|
||||
unsupportedResolution: 'Die aktuelle Auflösung wird nicht unterstützt',
|
||||
retrieving: 'Bildschirm wird abgerufen...',
|
||||
changingResolution: 'Auflösung wird gewechselt...',
|
||||
updateFailed: 'Der Bildschirm kann derzeit nicht aktualisiert werden',
|
||||
videoError: 'Fehler bei der Videoanzeige',
|
||||
noHdmi: 'Kein HDMI-Signal erkannt',
|
||||
unavailable: 'Der Bildschirm kann derzeit nicht angezeigt werden'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Tastatur',
|
||||
|
||||
@@ -76,7 +76,17 @@ const en = {
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetectTip:
|
||||
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.",
|
||||
resetHdmi: 'Reset HDMI'
|
||||
resetHdmi: 'Reset HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI screen error',
|
||||
unsupportedResolution: 'Current resolution is not supported',
|
||||
retrieving: 'Getting screen...',
|
||||
changingResolution: 'Switching resolution...',
|
||||
updateFailed: 'Screen cannot update right now',
|
||||
videoError: 'Video display error',
|
||||
noHdmi: 'No HDMI signal detected',
|
||||
unavailable: 'Screen cannot be displayed right now'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Keyboard',
|
||||
|
||||
@@ -78,7 +78,17 @@ const es = {
|
||||
frameDetect: 'Detectar fotogramas',
|
||||
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'
|
||||
resetHdmi: 'Reiniciar HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Error de imagen HDMI',
|
||||
unsupportedResolution: 'La resolución actual no es compatible',
|
||||
retrieving: 'Obteniendo pantalla...',
|
||||
changingResolution: 'Cambiando resolución...',
|
||||
updateFailed: 'La pantalla no puede actualizarse ahora',
|
||||
videoError: 'Error de visualización de video',
|
||||
noHdmi: 'No se detectó señal HDMI',
|
||||
unavailable: 'La pantalla no puede mostrarse ahora'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Teclado',
|
||||
|
||||
@@ -80,7 +80,17 @@ const fr = {
|
||||
frameDetect: 'Détection de trame',
|
||||
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'
|
||||
resetHdmi: 'Réinitialiser le HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Erreur d’image HDMI',
|
||||
unsupportedResolution: 'La résolution actuelle n’est pas prise en charge',
|
||||
retrieving: 'Récupération de l’image...',
|
||||
changingResolution: 'Changement de résolution...',
|
||||
updateFailed: 'L’image ne peut pas être mise à jour pour le moment',
|
||||
videoError: 'Erreur d’affichage vidéo',
|
||||
noHdmi: 'Aucun signal HDMI détecté',
|
||||
unavailable: 'L’image ne peut pas être affichée pour le moment'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Clavier',
|
||||
|
||||
@@ -79,7 +79,17 @@ const hu = {
|
||||
frameDetect: 'Képkocka-figyelés',
|
||||
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'
|
||||
resetHdmi: 'HDMI visszaállítása',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI-képernyőhiba',
|
||||
unsupportedResolution: 'A jelenlegi felbontás nem támogatott',
|
||||
retrieving: 'Kép lekérése...',
|
||||
changingResolution: 'Felbontás váltása...',
|
||||
updateFailed: 'A kép jelenleg nem frissíthető',
|
||||
videoError: 'Videómegjelenítési hiba',
|
||||
noHdmi: 'Nem észlelhető HDMI-jel',
|
||||
unavailable: 'A kép jelenleg nem jeleníthető meg'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Billentyűzet',
|
||||
|
||||
@@ -77,7 +77,17 @@ const id = {
|
||||
frameDetect: 'Deteksi bingkai',
|
||||
frameDetectTip:
|
||||
'Hitung selisih antar frame. Hentikan transmisi aliran video saat tidak ada perubahan yang terdeteksi di layar host jarak jauh.',
|
||||
resetHdmi: 'Atur ulang HDMI'
|
||||
resetHdmi: 'Atur ulang HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Kesalahan layar HDMI',
|
||||
unsupportedResolution: 'Resolusi saat ini tidak didukung',
|
||||
retrieving: 'Mengambil layar...',
|
||||
changingResolution: 'Mengganti resolusi...',
|
||||
updateFailed: 'Layar tidak dapat diperbarui saat ini',
|
||||
videoError: 'Kesalahan tampilan video',
|
||||
noHdmi: 'Sinyal HDMI tidak terdeteksi',
|
||||
unavailable: 'Layar tidak dapat ditampilkan saat ini'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Keyboard',
|
||||
|
||||
@@ -79,7 +79,17 @@ const it = {
|
||||
frameDetect: 'Rilevamento Frame',
|
||||
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'
|
||||
resetHdmi: 'Reimposta HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Errore schermata HDMI',
|
||||
unsupportedResolution: 'La risoluzione attuale non è supportata',
|
||||
retrieving: 'Acquisizione schermata...',
|
||||
changingResolution: 'Cambio risoluzione...',
|
||||
updateFailed: 'Lo schermo non può aggiornarsi al momento',
|
||||
videoError: 'Errore di visualizzazione video',
|
||||
noHdmi: 'Nessun segnale HDMI rilevato',
|
||||
unavailable: 'Lo schermo non può essere visualizzato al momento'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Tastiera',
|
||||
|
||||
@@ -77,7 +77,17 @@ const ja = {
|
||||
frameDetect: 'フレーム差分検出',
|
||||
frameDetectTip:
|
||||
'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します',
|
||||
resetHdmi: 'HDMI をリセット'
|
||||
resetHdmi: 'HDMI をリセット',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI 画面エラー',
|
||||
unsupportedResolution: '現在の解像度はサポートされていません',
|
||||
retrieving: '画面を取得中...',
|
||||
changingResolution: '解像度を切り替え中...',
|
||||
updateFailed: '現在、画面を更新できません',
|
||||
videoError: '映像表示エラー',
|
||||
noHdmi: 'HDMI 信号が検出されません',
|
||||
unavailable: '現在、画面を表示できません'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'キーボード',
|
||||
|
||||
@@ -76,7 +76,17 @@ const ko = {
|
||||
frameDetect: '프레임 탐지',
|
||||
frameDetectTip:
|
||||
'프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.',
|
||||
resetHdmi: 'HDMI 초기화'
|
||||
resetHdmi: 'HDMI 초기화',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI 화면 오류',
|
||||
unsupportedResolution: '현재 해상도는 지원되지 않습니다',
|
||||
retrieving: '화면을 가져오는 중...',
|
||||
changingResolution: '해상도를 전환하는 중...',
|
||||
updateFailed: '지금은 화면을 업데이트할 수 없습니다',
|
||||
videoError: '비디오 표시 오류',
|
||||
noHdmi: 'HDMI 신호가 감지되지 않았습니다',
|
||||
unavailable: '지금은 화면을 표시할 수 없습니다'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: '키보드',
|
||||
|
||||
@@ -78,7 +78,17 @@ const nb = {
|
||||
frameDetect: 'Bildefrekvensoppdagelse',
|
||||
frameDetectTip:
|
||||
'Kalkuler forskjellen mellom bilder. Stopper overføring av video når det ikke oppdages forskjell på den eksterne vertens skjerm.',
|
||||
resetHdmi: 'Tilbakestill HDMI'
|
||||
resetHdmi: 'Tilbakestill HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI-skjermfeil',
|
||||
unsupportedResolution: 'Gjeldende oppløsning støttes ikke',
|
||||
retrieving: 'Henter skjermbilde...',
|
||||
changingResolution: 'Bytter oppløsning...',
|
||||
updateFailed: 'Skjermen kan ikke oppdateres akkurat nå',
|
||||
videoError: 'Feil ved videovisning',
|
||||
noHdmi: 'Ingen HDMI-signal oppdaget',
|
||||
unavailable: 'Skjermen kan ikke vises akkurat nå'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Åpne tastatur',
|
||||
|
||||
@@ -79,7 +79,17 @@ const nl = {
|
||||
frameDetect: 'Frame detectie',
|
||||
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'
|
||||
resetHdmi: 'Reset HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI-schermfout',
|
||||
unsupportedResolution: 'De huidige resolutie wordt niet ondersteund',
|
||||
retrieving: 'Scherm ophalen...',
|
||||
changingResolution: 'Resolutie wisselen...',
|
||||
updateFailed: 'Het scherm kan nu niet worden bijgewerkt',
|
||||
videoError: 'Fout bij videoweergave',
|
||||
noHdmi: 'Geen HDMI-signaal gedetecteerd',
|
||||
unavailable: 'Het scherm kan nu niet worden weergegeven'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Toetsenbord',
|
||||
|
||||
@@ -78,7 +78,17 @@ const pl = {
|
||||
frameDetect: 'Wykrywanie klatek',
|
||||
frameDetectTip:
|
||||
'Obliczanie różnicy między klatkami. Zatrzymaj transmisję strumienia wideo, gdy na ekranie zdalnego hosta nie zostaną wykryte żadne zmiany.',
|
||||
resetHdmi: 'Resetuj HDMI'
|
||||
resetHdmi: 'Resetuj HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Błąd obrazu HDMI',
|
||||
unsupportedResolution: 'Bieżąca rozdzielczość nie jest obsługiwana',
|
||||
retrieving: 'Pobieranie obrazu...',
|
||||
changingResolution: 'Przełączanie rozdzielczości...',
|
||||
updateFailed: 'Nie można teraz zaktualizować obrazu',
|
||||
videoError: 'Błąd wyświetlania wideo',
|
||||
noHdmi: 'Nie wykryto sygnału HDMI',
|
||||
unavailable: 'Nie można teraz wyświetlić obrazu'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Klawiatura',
|
||||
|
||||
@@ -77,7 +77,17 @@ const pt_br = {
|
||||
frameDetect: 'Detecção de Quadros',
|
||||
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'
|
||||
resetHdmi: 'Redefinir HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Erro na imagem HDMI',
|
||||
unsupportedResolution: 'A resolução atual não é compatível',
|
||||
retrieving: 'Obtendo tela...',
|
||||
changingResolution: 'Alterando resolução...',
|
||||
updateFailed: 'A tela não pode ser atualizada agora',
|
||||
videoError: 'Erro na exibição de vídeo',
|
||||
noHdmi: 'Nenhum sinal HDMI detectado',
|
||||
unavailable: 'A tela não pode ser exibida agora'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Teclado',
|
||||
|
||||
@@ -77,7 +77,17 @@ const ru = {
|
||||
frameDetect: 'Экономия трафика',
|
||||
frameDetectTip:
|
||||
'Вычисляет разницу между кадрами и прекращает передачу видеопотока, если на экране удаленного узла не обнаружено никаких изменений.',
|
||||
resetHdmi: 'Перезагрузить HDMI подсистему'
|
||||
resetHdmi: 'Перезагрузить HDMI подсистему',
|
||||
captureStatus: {
|
||||
hdmiError: 'Ошибка изображения HDMI',
|
||||
unsupportedResolution: 'Текущее разрешение не поддерживается',
|
||||
retrieving: 'Получение изображения...',
|
||||
changingResolution: 'Переключение разрешения...',
|
||||
updateFailed: 'Сейчас невозможно обновить изображение',
|
||||
videoError: 'Ошибка отображения видео',
|
||||
noHdmi: 'Сигнал HDMI не обнаружен',
|
||||
unavailable: 'Сейчас невозможно отобразить изображение'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Клавиатура',
|
||||
|
||||
@@ -75,7 +75,17 @@ const se = {
|
||||
frameDetect: 'Ramdetection',
|
||||
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'
|
||||
resetHdmi: 'Återställ HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI-skärmfel',
|
||||
unsupportedResolution: 'Den aktuella upplösningen stöds inte',
|
||||
retrieving: 'Hämtar skärmen...',
|
||||
changingResolution: 'Byter upplösning...',
|
||||
updateFailed: 'Skärmen kan inte uppdateras just nu',
|
||||
videoError: 'Videovisningsfel',
|
||||
noHdmi: 'Ingen HDMI-signal upptäcktes',
|
||||
unavailable: 'Skärmen kan inte visas just nu'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Tangentbord',
|
||||
|
||||
@@ -75,7 +75,17 @@ const th = {
|
||||
frameDetect: 'ตรวจจับเฟรม',
|
||||
frameDetectTip:
|
||||
'ระบบจะคำนวณความแตกต่างระหว่างเฟรม และหยุดส่งสตรีมวิดีโอเมื่อไม่พบการเปลี่ยนแปลงบนหน้าจอของคอมพิวเตอร์ต้นทาง',
|
||||
resetHdmi: 'รีเช็ท HDMI'
|
||||
resetHdmi: 'รีเช็ท HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'ข้อผิดพลาดหน้าจอ HDMI',
|
||||
unsupportedResolution: 'ไม่รองรับความละเอียดปัจจุบัน',
|
||||
retrieving: 'กำลังดึงหน้าจอ...',
|
||||
changingResolution: 'กำลังเปลี่ยนความละเอียด...',
|
||||
updateFailed: 'ไม่สามารถอัปเดตหน้าจอได้ในขณะนี้',
|
||||
videoError: 'ข้อผิดพลาดในการแสดงวิดีโอ',
|
||||
noHdmi: 'ไม่พบสัญญาณ HDMI',
|
||||
unavailable: 'ไม่สามารถแสดงหน้าจอได้ในขณะนี้'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'คีบอร์ด',
|
||||
|
||||
@@ -78,7 +78,17 @@ const tr = {
|
||||
frameDetect: 'Kareleri algıla',
|
||||
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'
|
||||
resetHdmi: 'HDMI sıfırla',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI ekran hatası',
|
||||
unsupportedResolution: 'Geçerli çözünürlük desteklenmiyor',
|
||||
retrieving: 'Ekran alınıyor...',
|
||||
changingResolution: 'Çözünürlük değiştiriliyor...',
|
||||
updateFailed: 'Ekran şu anda güncellenemiyor',
|
||||
videoError: 'Video görüntüleme hatası',
|
||||
noHdmi: 'HDMI sinyali algılanmadı',
|
||||
unavailable: 'Ekran şu anda gösterilemiyor'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Klavye',
|
||||
|
||||
@@ -78,7 +78,17 @@ const uk = {
|
||||
frameDetect: 'Виявлення кадрів',
|
||||
frameDetectTip:
|
||||
'Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін.',
|
||||
resetHdmi: 'Перезавантажити HDMI підсистему'
|
||||
resetHdmi: 'Перезавантажити HDMI підсистему',
|
||||
captureStatus: {
|
||||
hdmiError: 'Помилка зображення HDMI',
|
||||
unsupportedResolution: 'Поточна роздільна здатність не підтримується',
|
||||
retrieving: 'Отримання зображення...',
|
||||
changingResolution: 'Перемикання роздільної здатності...',
|
||||
updateFailed: 'Зараз неможливо оновити зображення',
|
||||
videoError: 'Помилка відображення відео',
|
||||
noHdmi: 'Сигнал HDMI не виявлено',
|
||||
unavailable: 'Зараз неможливо показати зображення'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Клавіатура',
|
||||
|
||||
@@ -77,7 +77,17 @@ const vi = {
|
||||
frameDetect: 'Phát hiện khung hình',
|
||||
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'
|
||||
resetHdmi: 'Đặt lại HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'Lỗi màn hình HDMI',
|
||||
unsupportedResolution: 'Độ phân giải hiện tại không được hỗ trợ',
|
||||
retrieving: 'Đang lấy màn hình...',
|
||||
changingResolution: 'Đang chuyển độ phân giải...',
|
||||
updateFailed: 'Hiện không thể cập nhật màn hình',
|
||||
videoError: 'Lỗi hiển thị video',
|
||||
noHdmi: 'Không phát hiện tín hiệu HDMI',
|
||||
unavailable: 'Hiện không thể hiển thị màn hình'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Bàn phím',
|
||||
|
||||
@@ -73,7 +73,17 @@ const zh = {
|
||||
qualityLow: '低',
|
||||
frameDetect: '帧差检测',
|
||||
frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流',
|
||||
resetHdmi: '重置 HDMI'
|
||||
resetHdmi: '重置 HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI 画面异常',
|
||||
unsupportedResolution: '当前分辨率不支持',
|
||||
retrieving: '正在获取画面...',
|
||||
changingResolution: '正在切换分辨率...',
|
||||
updateFailed: '画面暂时无法更新',
|
||||
videoError: '视频显示异常',
|
||||
noHdmi: '未检测到 HDMI 信号',
|
||||
unavailable: '画面暂时无法显示'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: '键盘',
|
||||
|
||||
@@ -73,7 +73,17 @@ const zh_tw = {
|
||||
qualityLow: '低',
|
||||
frameDetect: '影格檢測',
|
||||
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。',
|
||||
resetHdmi: '重置 HDMI'
|
||||
resetHdmi: '重置 HDMI',
|
||||
captureStatus: {
|
||||
hdmiError: 'HDMI 畫面異常',
|
||||
unsupportedResolution: '目前解析度不支援',
|
||||
retrieving: '正在取得畫面...',
|
||||
changingResolution: '正在切換解析度...',
|
||||
updateFailed: '畫面暫時無法更新',
|
||||
videoError: '影片顯示異常',
|
||||
noHdmi: '未偵測到 HDMI 訊號',
|
||||
unavailable: '畫面暫時無法顯示'
|
||||
}
|
||||
},
|
||||
keyboard: {
|
||||
title: '鍵盤',
|
||||
|
||||
2
web/src/pages/desktop/capture-status/index.ts
Normal file
2
web/src/pages/desktop/capture-status/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CaptureStatusOverlay } from './overlay';
|
||||
export { useCaptureStatus } from './use-capture-status';
|
||||
74
web/src/pages/desktop/capture-status/model.ts
Normal file
74
web/src/pages/desktop/capture-status/model.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export const CAPTURE_STATUS_EVENT = 'capture-status';
|
||||
|
||||
export type CaptureStatus = {
|
||||
ok: boolean;
|
||||
result: number;
|
||||
message: string;
|
||||
mode: string;
|
||||
severity: 'error' | 'warning' | '';
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function parseCaptureStatusMessage(message: { data: unknown }): CaptureStatus | null {
|
||||
if (typeof message.data !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const envelope = JSON.parse(message.data) as { data?: unknown };
|
||||
if (typeof envelope.data !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = JSON.parse(envelope.data) as Partial<CaptureStatus>;
|
||||
if (typeof status.ok !== 'boolean' || typeof status.result !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: status.ok,
|
||||
result: status.result,
|
||||
message: typeof status.message === 'string' ? status.message : '',
|
||||
mode: typeof status.mode === 'string' ? status.mode : '',
|
||||
severity: status.severity === 'warning' ? 'warning' : status.ok ? '' : 'error',
|
||||
updatedAt: typeof status.updatedAt === 'string' ? status.updatedAt : ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCaptureStatusMessageKey(result: number) {
|
||||
switch (result) {
|
||||
case -7:
|
||||
return 'screen.captureStatus.hdmiError';
|
||||
case -6:
|
||||
return 'screen.captureStatus.unsupportedResolution';
|
||||
case -5:
|
||||
return 'screen.captureStatus.retrieving';
|
||||
case -4:
|
||||
return 'screen.captureStatus.changingResolution';
|
||||
case -3:
|
||||
return 'screen.captureStatus.updateFailed';
|
||||
case -2:
|
||||
return 'screen.captureStatus.videoError';
|
||||
case -1:
|
||||
return 'screen.captureStatus.noHdmi';
|
||||
default:
|
||||
return result < 0 ? 'screen.captureStatus.unavailable' : '';
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAcceptCaptureStatus(current: CaptureStatus | null, next: CaptureStatus) {
|
||||
if (!current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getCaptureStatusTimestamp(next) >= getCaptureStatusTimestamp(current);
|
||||
}
|
||||
|
||||
function getCaptureStatusTimestamp(status: CaptureStatus) {
|
||||
const timestamp = Date.parse(status.updatedAt);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
30
web/src/pages/desktop/capture-status/overlay.tsx
Normal file
30
web/src/pages/desktop/capture-status/overlay.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CaptureStatus, getCaptureStatusMessageKey } from './model';
|
||||
|
||||
export function CaptureStatusOverlay({ status }: { status: CaptureStatus | null }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!status || status.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageKey = getCaptureStatusMessageKey(status.result);
|
||||
const isWarning = status.severity === 'warning';
|
||||
|
||||
const toneClass = isWarning
|
||||
? 'border-amber-500/50 bg-neutral-900/90 text-amber-400 shadow-amber-900/20'
|
||||
: 'border-red-500/50 bg-neutral-900/90 text-red-400 shadow-red-900/20';
|
||||
|
||||
const Icon = isWarning ? AlertTriangle : AlertCircle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute left-1/2 top-1/2 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 items-center gap-2.5 rounded-lg border px-4 py-2.5 text-sm font-medium shadow-xl backdrop-blur-md ${toneClass}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0 animate-pulse" />
|
||||
<span className="min-w-0 truncate">{t(messageKey)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
web/src/pages/desktop/capture-status/use-capture-status.ts
Normal file
39
web/src/pages/desktop/capture-status/use-capture-status.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
import {
|
||||
CAPTURE_STATUS_EVENT,
|
||||
CaptureStatus,
|
||||
parseCaptureStatusMessage,
|
||||
shouldAcceptCaptureStatus
|
||||
} from './model';
|
||||
|
||||
export function useCaptureStatus(activeVideoMode: string) {
|
||||
const [captureStatus, setCaptureStatus] = useState<CaptureStatus | null>(null);
|
||||
const latestStatusRef = useRef<CaptureStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
latestStatusRef.current = null;
|
||||
setCaptureStatus(null);
|
||||
|
||||
if (!activeVideoMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on(CAPTURE_STATUS_EVENT, (message) => {
|
||||
const status = parseCaptureStatusMessage(message);
|
||||
if (!status || status.mode !== activeVideoMode) {
|
||||
return;
|
||||
}
|
||||
if (!shouldAcceptCaptureStatus(latestStatusRef.current, status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestStatusRef.current = status;
|
||||
setCaptureStatus(status.ok ? null : status);
|
||||
});
|
||||
}, [activeVideoMode]);
|
||||
|
||||
return captureStatus;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { CaptureStatusOverlay, useCaptureStatus } from './capture-status';
|
||||
import { Keyboard } from './keyboard';
|
||||
import { Menu } from './menu';
|
||||
import { Mouse } from './mouse';
|
||||
@@ -19,10 +20,26 @@ import { ActionOverlay } from './picoclaw/action-overlay.tsx';
|
||||
import { Screen } from './screen';
|
||||
import { VirtualKeyboard } from './virtual-keyboard';
|
||||
|
||||
function getVideoMode() {
|
||||
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
|
||||
|
||||
const cookieVideoMode = storage.getVideoMode();
|
||||
if (cookieVideoMode) {
|
||||
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
return cookieVideoMode;
|
||||
}
|
||||
|
||||
return defaultVideoMode;
|
||||
}
|
||||
|
||||
export const Desktop = () => {
|
||||
const { t } = useTranslation();
|
||||
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||
const [activeVideoMode] = useState(getVideoMode);
|
||||
const [picoclawSidebarWidth, setPicoclawSidebarWidth] = useState(420);
|
||||
const captureStatus = useCaptureStatus(activeVideoMode);
|
||||
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
@@ -31,8 +48,7 @@ export const Desktop = () => {
|
||||
useEffect(() => {
|
||||
client.connect();
|
||||
|
||||
const mode = getVideoMode();
|
||||
setVideoMode(mode);
|
||||
setVideoMode(activeVideoMode);
|
||||
|
||||
const res = storage.getResolution() || { width: 0, height: 0 };
|
||||
setResolution(res);
|
||||
@@ -40,21 +56,7 @@ export const Desktop = () => {
|
||||
return () => {
|
||||
client.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getVideoMode() {
|
||||
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
|
||||
|
||||
const cookieVideoMode = storage.getVideoMode();
|
||||
if (cookieVideoMode) {
|
||||
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
return cookieVideoMode;
|
||||
}
|
||||
|
||||
return defaultVideoMode;
|
||||
}
|
||||
}, [activeVideoMode, setResolution, setVideoMode]);
|
||||
|
||||
function handleSplitterResize(sizes: number[]) {
|
||||
const nextSidebarWidth = sizes[1];
|
||||
@@ -81,6 +83,7 @@ export const Desktop = () => {
|
||||
<Splitter.Panel min="45%">
|
||||
<div className="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-black">
|
||||
<Screen />
|
||||
<CaptureStatusOverlay status={captureStatus} />
|
||||
</div>
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel
|
||||
|
||||
Reference in New Issue
Block a user