diff --git a/server/proto/vm.go b/server/proto/vm.go index aa4c2d5..3467e13 100644 --- a/server/proto/vm.go +++ b/server/proto/vm.go @@ -152,3 +152,41 @@ type GetWebTitleRsp struct { type SetTlsReq struct { Enabled bool `validate:"omitempty"` } + +type InputRegion struct { + Mode string `json:"mode"` + FrameWidth int `json:"frameWidth"` + FrameHeight int `json:"frameHeight"` + Left int `json:"left"` + Top int `json:"top"` + Width int `json:"width"` + Height int `json:"height"` + Resolutions []OriginalResolution `json:"resolutions,omitempty"` + SelectedResolution string `json:"selectedResolution"` +} + +type OriginalResolution struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type SetInputRegionReq struct { + Mode string `json:"mode"` + FrameWidth *int `json:"frameWidth,omitempty"` + FrameHeight *int `json:"frameHeight,omitempty"` + Left *int `json:"left,omitempty"` + Top *int `json:"top,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + Resolutions *[]OriginalResolution `json:"resolutions,omitempty"` + SelectedResolution *string `json:"selectedResolution,omitempty"` +} + +type GetInputRegionRsp struct { + InputRegion +} + +type GetInputResolutionRsp struct { + Width int `json:"width"` + Height int `json:"height"` +} diff --git a/server/router/vm.go b/server/router/vm.go index 4b4b58a..2cf555f 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -24,6 +24,10 @@ func vmRouter(r *gin.Engine) { api.GET("/vm/gpio", service.GetGpio) // get gpio api.POST("/vm/screen", service.SetScreen) // update screen + api.GET("/vm/input-region", service.GetInputRegion) + api.POST("/vm/input-region", service.SetInputRegion) + api.GET("/vm/input-resolution", service.GetInputResolution) + admin.GET("/vm/terminal", service.Terminal) // web terminal admin.GET("/vm/script", service.GetScripts) // get script diff --git a/server/service/vm/input_region.go b/server/service/vm/input_region.go new file mode 100644 index 0000000..aa87c3b --- /dev/null +++ b/server/service/vm/input_region.go @@ -0,0 +1,283 @@ +package vm + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "NanoKVM-Server/proto" + + "github.com/gin-gonic/gin" +) + +var ( + inputRegionFile = "/etc/kvm/input-region.json" + inputRegionMu sync.RWMutex +) + +func (s *Service) GetInputResolution(c *gin.Context) { + var rsp proto.Response + width, widthErr := readPositiveInt("/kvmapp/kvm/width") + height, heightErr := readPositiveInt("/kvmapp/kvm/height") + if widthErr != nil || heightErr != nil { + rsp.ErrRsp(c, -1, "failed to read input resolution") + return + } + rsp.OkRspWithData(c, &proto.GetInputResolutionRsp{Width: width, Height: height}) +} + +func readPositiveInt(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + value, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || value <= 0 { + return 0, errors.New("invalid positive integer") + } + return value, nil +} + +func (s *Service) GetInputRegion(c *gin.Context) { + var rsp proto.Response + + inputRegionMu.RLock() + region, err := readInputRegion(inputRegionFile) + inputRegionMu.RUnlock() + if errors.Is(err, os.ErrNotExist) { + rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}}) + return + } + if err != nil { + rsp.ErrRsp(c, -1, "failed to read input region") + return + } + if region == nil { + rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}}) + return + } + + rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: *region}) +} + +func (s *Service) SetInputRegion(c *gin.Context) { + var req proto.SetInputRegionReq + var rsp proto.Response + + if err := c.ShouldBindJSON(&req); err != nil { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + + inputRegionMu.Lock() + defer inputRegionMu.Unlock() + + if req.Mode == "off" || req.Mode == "auto" || + (req.Mode == "manual" && req.FrameWidth == nil && req.FrameHeight == nil && + req.Left == nil && req.Top == nil && req.Width == nil && req.Height == nil) { + region, err := readInputRegion(inputRegionFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + rsp.ErrRsp(c, -2, "failed to read input region") + return + } + if region == nil { + region = &proto.InputRegion{} + } + region.Mode = req.Mode + if req.Resolutions != nil { + if err := validateOriginalResolutions(*req.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid original resolutions") + return + } + region.Resolutions = *req.Resolutions + } + if req.SelectedResolution != nil { + region.SelectedResolution = *req.SelectedResolution + } + if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid selected resolution") + return + } + if err := writeInputRegion(inputRegionFile, *region); err != nil { + rsp.ErrRsp(c, -2, "failed to save input region mode") + return + } + rsp.OkRsp(c) + return + } + if req.Mode != "manual" { + rsp.ErrRsp(c, -1, "invalid input region mode") + return + } + region, err := inputRegionFromRequest(req) + if err != nil { + rsp.ErrRsp(c, -1, "invalid input region") + return + } + previous, readErr := readInputRegion(inputRegionFile) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + rsp.ErrRsp(c, -2, "failed to read input region") + return + } + if previous != nil { + region.Resolutions = previous.Resolutions + region.SelectedResolution = previous.SelectedResolution + } + if req.Resolutions != nil { + if err := validateOriginalResolutions(*req.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid original resolutions") + return + } + region.Resolutions = *req.Resolutions + } + if req.SelectedResolution != nil { + region.SelectedResolution = *req.SelectedResolution + } + if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid selected resolution") + return + } + if err := writeInputRegion(inputRegionFile, region); err != nil { + rsp.ErrRsp(c, -2, "failed to save input region") + return + } + + rsp.OkRsp(c) +} + +func validateOriginalResolutions(resolutions []proto.OriginalResolution) error { + seen := make(map[proto.OriginalResolution]struct{}, len(resolutions)) + for _, resolution := range resolutions { + if resolution.Width <= 0 || resolution.Height <= 0 { + return errors.New("resolution dimensions must be positive") + } + if _, ok := seen[resolution]; ok { + return errors.New("duplicate resolution") + } + seen[resolution] = struct{}{} + } + return nil +} + +func validateSelectedResolution(selected string, resolutions []proto.OriginalResolution) error { + if selected == "" { + return nil + } + for _, resolution := range resolutions { + if selected == resolutionKey(resolution) { + return nil + } + } + return errors.New("selected resolution not found") +} + +func resolutionKey(resolution proto.OriginalResolution) string { + return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height) +} + +func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) { + if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil || + req.Top == nil || req.Width == nil || req.Height == nil { + return proto.InputRegion{}, errors.New("input region must be complete") + } + + region := proto.InputRegion{ + Mode: "manual", + FrameWidth: *req.FrameWidth, + FrameHeight: *req.FrameHeight, + Left: *req.Left, + Top: *req.Top, + Width: *req.Width, + Height: *req.Height, + } + return region, validateInputRegion(region) +} + +func validateInputRegion(region proto.InputRegion) error { + if region.Mode == "off" || region.Mode == "auto" { + return nil + } + if region.Mode == "manual" && region.FrameWidth == 0 && region.FrameHeight == 0 { + return nil + } + if region.Mode != "manual" { + return errors.New("invalid input region mode") + } + if region.FrameWidth <= 0 || region.FrameHeight <= 0 { + return errors.New("frame dimensions must be positive") + } + if region.Left < 0 || region.Top < 0 || region.Width <= 0 || region.Height <= 0 { + return errors.New("region dimensions must be positive and offsets non-negative") + } + if region.Left > region.FrameWidth-region.Width || region.Top > region.FrameHeight-region.Height { + return errors.New("region must be inside frame") + } + return nil +} + +func readInputRegion(path string) (*proto.InputRegion, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var region *proto.InputRegion + if err := json.Unmarshal(data, ®ion); err != nil { + return nil, nil + } + if region == nil { + return nil, nil + } + if region.Mode == "" { + region.Mode = "manual" + } + if err := validateInputRegion(*region); err != nil { + return nil, nil + } + return region, nil +} + +func writeInputRegion(path string, region proto.InputRegion) error { + data, err := json.Marshal(region) + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, ".input-region-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func removeInputRegion(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index b2a1d39..47af831 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -1,3 +1,4 @@ +import { ControlRegionMode, InputRegion, OriginalResolution } from '@/types'; import { http } from '@/lib/http.ts'; // get NanoKVM information @@ -33,6 +34,51 @@ export function updateScreen(type: string, value: number) { return http.post('/api/vm/screen', data); } +// get the device-level absolute mouse input region +export function getInputRegion() { + return http.get('/api/vm/input-region'); +} + +export function getInputResolution() { + return http.get('/api/vm/input-resolution'); +} + +// save the device-level absolute mouse input region +export function setInputRegion(region: InputRegion) { + return http.post('/api/vm/input-region', { mode: 'manual', ...region }); +} + +export function setInputRegionConfig(region: InputRegion, selectedResolution: string) { + return http.post('/api/vm/input-region', { + mode: 'manual', + ...region, + selectedResolution + }); +} + +export function setControlRegionMode(mode: ControlRegionMode) { + return http.post('/api/vm/input-region', { mode }); +} + +export function setOriginalResolutions(mode: ControlRegionMode, resolutions: OriginalResolution[]) { + return http.post('/api/vm/input-region', { mode, resolutions }); +} + +export function setOriginalResolutionConfig( + resolutions: OriginalResolution[], + selectedResolution: string +) { + return http.post('/api/vm/input-region', { + mode: 'manual', + resolutions, + selectedResolution + }); +} + +export function setSelectedOriginalResolution(selectedResolution: string) { + return http.post('/api/vm/input-region', { mode: 'manual', selectedResolution }); +} + // get memory limit export function getMemoryLimit() { return http.get('/api/vm/memory/limit'); diff --git a/web/src/components/menu-item.tsx b/web/src/components/menu-item.tsx index 5353221..d1f7c4f 100644 --- a/web/src/components/menu-item.tsx +++ b/web/src/components/menu-item.tsx @@ -1,9 +1,9 @@ -import { ReactNode, useState } from 'react'; +import { ReactNode, useEffect, useRef, useState } from 'react'; import { Popover, Tooltip } from 'antd'; -import { useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useMediaQuery } from 'react-responsive'; -import { submenuOpenCountAtom } from '@/jotai/settings.ts'; +import { menuCloseSignalAtom, submenuOpenCountAtom } from '@/jotai/settings.ts'; type MenuItemProps = { title: string; @@ -24,9 +24,27 @@ export const MenuItem = ({ }: MenuItemProps) => { const isBigScreen = useMediaQuery({ minWidth: 640 }); const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom); + const menuCloseSignal = useAtomValue(menuCloseSignalAtom); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false); + const handledCloseSignalRef = useRef(0); + + useEffect(() => { + if ( + menuCloseSignal === 0 || + menuCloseSignal === handledCloseSignalRef.current || + !isPopoverOpen + ) { + return; + } + + handledCloseSignalRef.current = menuCloseSignal; + setIsPopoverOpen(false); + setIsTooltipOpen(false); + setSubmenuOpenCount((count) => Math.max(0, count - 1)); + onOpenChange?.(false); + }, [isPopoverOpen, menuCloseSignal, onOpenChange, setSubmenuOpenCount]); function togglePopover(open: boolean) { setIsTooltipOpen(false); diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index 558c737..30f2e04 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -64,6 +64,36 @@ const ca = { video: 'Mode de vídeo', videoDirectTips: "Activa HTTPS a 'Configuració > Dispositiu' per utilitzar aquest mode", resolution: 'Resolució', + controlRegion: { + title: 'Calibratge del ratolí', + description: + 'Utilitzeu aquesta opció quan el dispositiu controlat tingui una resolució que no sigui 16:9 i el cursor estigui desalineat horitzontalment o verticalment.', + off: 'Desactivat', + auto: 'Automàtic', + autoWarning: + "El calibratge pot fallar quan l'aplicació de l'usuari tingui un fons completament negre.", + manual: 'Manual', + originalResolution: 'Resolució original', + selectResolution: 'Selecciona la resolució original', + useSelectedArea: "Utilitza la resolució de l'àrea seleccionada", + addResolution: 'Afegeix una resolució personalitzada', + add: 'Afegeix', + duplicateResolution: 'Aquesta resolució ja existeix.', + width: 'Amplada', + height: 'Alçada', + apply: 'Calcula i aplica', + invalidResolution: 'Introduïu una resolució original vàlida quan el vídeo estigui llest.', + select: "Selecciona l'àrea", + clear: 'Restaura la detecció automàtica', + saveFailed: "No s'ha pogut desar l'àrea d'entrada.", + tooSmall: "L'àrea seleccionada és massa petita.", + previewUnavailable: 'Vista prèvia no disponible', + clearConfirm: 'Voleu restaurar la detecció automàtica de vores negres?', + dragHint: "Arrossegueu per seleccionar l'àrea de l'escriptori remot", + finish: 'Fet', + confirm: 'Confirma', + cancel: 'Cancel·la' + }, auto: 'Automàtic', autoTips: "Poden aparèixer talls o desajustos del ratolí en certes resolucions. Prova a canviar la resolució de l'amfitrió remot o desactiva el mode automàtic.", diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 2016f2b..7d35fdf 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -65,6 +65,35 @@ const cz = { video: 'Režim videa', videoDirectTips: 'Chcete-li používat tento režim, povolte HTTPS v "Nastavení > Zařízení"', resolution: 'Rozlišení', + controlRegion: { + title: 'Kalibrace myši', + description: + 'Toto nastavení použijte, pokud ovládané zařízení používá jiné rozlišení než 16:9 a kurzor je vodorovně nebo svisle posunutý.', + off: 'Vypnuto', + auto: 'Automaticky', + autoWarning: 'Kalibrace může selhat, pokud má uživatelská aplikace zcela černé pozadí.', + manual: 'Ručně', + originalResolution: 'Původní rozlišení', + selectResolution: 'Vyberte původní rozlišení', + useSelectedArea: 'Použít rozlišení vybrané oblasti', + addResolution: 'Přidat vlastní rozlišení', + add: 'Přidat', + duplicateResolution: 'Toto rozlišení již existuje.', + width: 'Šířka', + height: 'Výška', + apply: 'Vypočítat a použít', + invalidResolution: 'Po načtení videa zadejte platné původní rozlišení.', + select: 'Vybrat oblast', + clear: 'Obnovit automatickou detekci', + saveFailed: 'Vstupní oblast se nepodařilo uložit.', + tooSmall: 'Vybraná oblast je příliš malá.', + previewUnavailable: 'Náhled není k dispozici', + clearConfirm: 'Obnovit automatickou detekci černých okrajů?', + dragHint: 'Tažením vyberte oblast vzdálené plochy', + finish: 'Hotovo', + confirm: 'Potvrdit', + cancel: 'Zrušit' + }, auto: 'Automatické', autoTips: 'Může docházet k trhání obrazu nebo posunu myši při určitých rozlišeních. Zvažte úpravu rozlišení vzdáleného hostitele nebo vypněte automatický režim.', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 66b8e96..614c7d8 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -64,6 +64,36 @@ const da = { video: 'Videotilstand', videoDirectTips: 'Aktiver HTTPS i "Indstillinger > Enhed" for at bruge denne tilstand', resolution: 'Opløsning', + controlRegion: { + title: 'Musekalibrering', + description: + 'Brug denne indstilling, når den styrede enhed bruger en opløsning, der ikke er 16:9, og markøren er forskudt vandret eller lodret.', + off: 'Fra', + auto: 'Automatisk', + autoWarning: + 'Kalibreringen kan mislykkes, hvis brugerprogrammet har en helt sort baggrund.', + manual: 'Manuel', + originalResolution: 'Oprindelig opløsning', + selectResolution: 'Vælg oprindelig opløsning', + useSelectedArea: 'Brug det valgte områdes opløsning', + addResolution: 'Tilføj brugerdefineret opløsning', + add: 'Tilføj', + duplicateResolution: 'Denne opløsning findes allerede.', + width: 'Bredde', + height: 'Højde', + apply: 'Beregn og anvend', + invalidResolution: 'Indtast en gyldig oprindelig opløsning, når videoen er klar.', + select: 'Vælg område', + clear: 'Gendan automatisk registrering', + saveFailed: 'Inputområdet kunne ikke gemmes.', + tooSmall: 'Det valgte område er for lille.', + previewUnavailable: 'Forhåndsvisning er ikke tilgængelig', + clearConfirm: 'Gendan automatisk registrering af sorte kanter?', + dragHint: 'Træk for at vælge fjernskrivebordets område', + finish: 'Færdig', + confirm: 'Bekræft', + cancel: 'Annuller' + }, auto: 'Automatisk', autoTips: 'Screen-tearing eller mouse-offset kan opstå ved enkelte opløsninger. Hvis du oplever dette, kan du prøve at justere fjerncomputerens skærmopløsning eller deaktivere automatisk tilstand.', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 49d91ff..181764c 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -67,6 +67,37 @@ const de = { videoDirectTips: 'Aktivieren Sie HTTPS unter „Einstellungen > Gerät“, um diesen Modus zu verwenden', resolution: 'Auflösung', + controlRegion: { + title: 'Mauskalibrierung', + description: + 'Verwenden Sie diese Einstellung, wenn das gesteuerte Gerät eine andere Auflösung als 16:9 verwendet und der Mauszeiger horizontal oder vertikal versetzt ist.', + off: 'Aus', + auto: 'Automatisch', + autoWarning: + 'Die Kalibrierung kann fehlschlagen, wenn die Benutzeranwendung einen vollständig schwarzen Hintergrund hat.', + manual: 'Manuell', + originalResolution: 'Originalauflösung', + selectResolution: 'Originalauflösung auswählen', + useSelectedArea: 'Auflösung des ausgewählten Bereichs verwenden', + addResolution: 'Benutzerdefinierte Auflösung hinzufügen', + add: 'Hinzufügen', + duplicateResolution: 'Diese Auflösung ist bereits vorhanden.', + width: 'Breite', + height: 'Höhe', + apply: 'Berechnen und anwenden', + invalidResolution: + 'Geben Sie eine gültige Originalauflösung ein, sobald das Video bereit ist.', + select: 'Bereich auswählen', + clear: 'Automatische Erkennung wiederherstellen', + saveFailed: 'Der Eingabebereich konnte nicht gespeichert werden.', + tooSmall: 'Der ausgewählte Bereich ist zu klein.', + previewUnavailable: 'Vorschau nicht verfügbar', + clearConfirm: 'Automatische Erkennung schwarzer Ränder wiederherstellen?', + dragHint: 'Ziehen Sie, um den Remote-Desktop-Bereich auszuwählen', + finish: 'Fertig', + confirm: 'Bestätigen', + cancel: 'Abbrechen' + }, auto: 'Automatisch', autoTips: 'Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 23c1c38..b4bbe9b 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -66,6 +66,35 @@ const en = { video: 'Video Mode', videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode', resolution: 'Resolution', + controlRegion: { + title: 'Mouse Calibration', + description: + 'Use this setting when the controlled device uses a non-16:9 resolution and the cursor is misaligned horizontally or vertically.', + off: 'Off', + auto: 'Auto', + autoWarning: 'Calibration may fail when the user application has a pure black background.', + manual: 'Manual', + originalResolution: 'Original Resolution', + selectResolution: 'Select original resolution', + useSelectedArea: 'Use selected area resolution', + addResolution: 'Add custom resolution', + add: 'Add', + duplicateResolution: 'This resolution already exists.', + width: 'Width', + height: 'Height', + apply: 'Calculate and Apply', + invalidResolution: 'Enter a valid original resolution after the video is ready.', + select: 'Select Area', + clear: 'Restore Automatic', + saveFailed: 'Failed to save the input area.', + tooSmall: 'The selected area is too small.', + previewUnavailable: 'Preview unavailable', + clearConfirm: 'Restore automatic black-border detection?', + dragHint: 'Drag to select the remote desktop area', + finish: 'Done', + confirm: 'Confirm', + cancel: 'Cancel' + }, auto: 'Automatic', autoTips: "Screen tearing or mouse offset may occur at specific resolutions. Consider adjusting the remote host's resolution or disable automatic mode.", diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 5e7c4e7..928aca4 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -65,6 +65,36 @@ const es = { video: 'Modo de vídeo', videoDirectTips: 'Habilita HTTPS en "Ajustes > Dispositivo" para usar este modo', resolution: 'Resolución', + controlRegion: { + title: 'Calibración del ratón', + description: + 'Utilice este ajuste cuando el dispositivo controlado use una resolución distinta de 16:9 y el cursor esté desalineado horizontal o verticalmente.', + off: 'Desactivado', + auto: 'Automático', + autoWarning: + 'La calibración puede fallar si la aplicación del usuario tiene un fondo completamente negro.', + manual: 'Manual', + originalResolution: 'Resolución original', + selectResolution: 'Seleccionar resolución original', + useSelectedArea: 'Usar la resolución del área seleccionada', + addResolution: 'Añadir resolución personalizada', + add: 'Añadir', + duplicateResolution: 'Esta resolución ya existe.', + width: 'Ancho', + height: 'Alto', + apply: 'Calcular y aplicar', + invalidResolution: 'Introduzca una resolución original válida cuando el vídeo esté listo.', + select: 'Seleccionar área', + clear: 'Restaurar detección automática', + saveFailed: 'No se pudo guardar el área de entrada.', + tooSmall: 'El área seleccionada es demasiado pequeña.', + previewUnavailable: 'Vista previa no disponible', + clearConfirm: '¿Restaurar la detección automática de bordes negros?', + dragHint: 'Arrastre para seleccionar el área del escritorio remoto', + finish: 'Listo', + confirm: 'Confirmar', + cancel: 'Cancelar' + }, auto: 'Automático', autoTips: 'En determinadas resoluciones pueden producirse rasgado de imagen (tearing) o desplazamiento del ratón. Prueba a ajustar la resolución del host remoto o desactiva el modo automático.', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 43a31dc..a28aa11 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -67,6 +67,36 @@ const fr = { video: 'Mode vidéo', videoDirectTips: 'Activez HTTPS dans "Paramètres > Appareil" pour utiliser ce mode', resolution: 'Résolution', + controlRegion: { + title: 'Étalonnage de la souris', + description: + "Utilisez ce réglage lorsque l'appareil contrôlé utilise une résolution autre que 16:9 et que le curseur est décalé horizontalement ou verticalement.", + off: 'Désactivé', + auto: 'Automatique', + autoWarning: + "L'étalonnage peut échouer si l'application utilisateur présente un arrière-plan entièrement noir.", + manual: 'Manuel', + originalResolution: "Résolution d'origine", + selectResolution: "Sélectionner la résolution d'origine", + useSelectedArea: 'Utiliser la résolution de la zone sélectionnée', + addResolution: 'Ajouter une résolution personnalisée', + add: 'Ajouter', + duplicateResolution: 'Cette résolution existe déjà.', + width: 'Largeur', + height: 'Hauteur', + apply: 'Calculer et appliquer', + invalidResolution: "Saisissez une résolution d'origine valide une fois la vidéo prête.", + select: 'Sélectionner une zone', + clear: 'Rétablir la détection automatique', + saveFailed: "Échec de l'enregistrement de la zone d'entrée.", + tooSmall: 'La zone sélectionnée est trop petite.', + previewUnavailable: 'Aperçu indisponible', + clearConfirm: 'Rétablir la détection automatique des bordures noires ?', + dragHint: 'Faites glisser pour sélectionner la zone du bureau distant', + finish: 'Terminé', + confirm: 'Confirmer', + cancel: 'Annuler' + }, auto: 'Automatique', autoTips: "Sous certaines résolutions, il peut y avoir des artefacts visuels ou un décalage de la souris. Veuillez ajuster la résolution de l'hôte distant ou désactiver le mode automatique.", diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index bacdbd7..a6a2a0d 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -66,6 +66,36 @@ const hu = { videoDirectTips: 'Engedélyezze az HTTPS elemet a "Beállítások > Eszköz" menüpontban ennek a módnak a használatához', resolution: 'Felbontás', + controlRegion: { + title: 'Egérkalibrálás', + description: + 'Akkor használja ezt a beállítást, ha a vezérelt eszköz nem 16:9 képarányú felbontást használ, és a kurzor vízszintesen vagy függőlegesen eltolódik.', + off: 'Kikapcsolva', + auto: 'Automatikus', + autoWarning: + 'A kalibrálás sikertelen lehet, ha a felhasználói alkalmazás háttere teljesen fekete.', + manual: 'Kézi', + originalResolution: 'Eredeti felbontás', + selectResolution: 'Válassza ki az eredeti felbontást', + useSelectedArea: 'A kijelölt terület felbontásának használata', + addResolution: 'Egyéni felbontás hozzáadása', + add: 'Hozzáadás', + duplicateResolution: 'Ez a felbontás már létezik.', + width: 'Szélesség', + height: 'Magasság', + apply: 'Számítás és alkalmazás', + invalidResolution: 'A videó betöltése után adjon meg érvényes eredeti felbontást.', + select: 'Terület kijelölése', + clear: 'Automatikus felismerés visszaállítása', + saveFailed: 'Nem sikerült menteni a bemeneti területet.', + tooSmall: 'A kijelölt terület túl kicsi.', + previewUnavailable: 'Az előnézet nem érhető el', + clearConfirm: 'Visszaállítja a fekete szegélyek automatikus felismerését?', + dragHint: 'Húzással jelölje ki a távoli asztal területét', + finish: 'Kész', + confirm: 'Megerősítés', + cancel: 'Mégse' + }, auto: 'Automatikus', autoTips: 'Bizonyos felbontások esetén képernyőszakadás vagy egéreltolódás léphet fel. Fontolja meg a távoli gép felbontásának módosítását vagy az automatikus mód kikapcsolását.', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 01aa0a1..9a484af 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -64,6 +64,36 @@ const id = { video: 'Mode Video', videoDirectTips: 'Aktifkan HTTPS di "Pengaturan > Perangkat" untuk menggunakan mode ini', resolution: 'Resolusi', + controlRegion: { + title: 'Kalibrasi Tetikus', + description: + 'Gunakan pengaturan ini saat perangkat yang dikontrol menggunakan resolusi selain 16:9 dan posisi kursor tidak sejajar secara horizontal atau vertikal.', + off: 'Nonaktif', + auto: 'Otomatis', + autoWarning: + 'Kalibrasi mungkin gagal jika aplikasi pengguna menggunakan latar belakang hitam pekat.', + manual: 'Manual', + originalResolution: 'Resolusi Asli', + selectResolution: 'Pilih resolusi asli', + useSelectedArea: 'Gunakan resolusi area yang dipilih', + addResolution: 'Tambahkan resolusi khusus', + add: 'Tambah', + duplicateResolution: 'Resolusi ini sudah ada.', + width: 'Lebar', + height: 'Tinggi', + apply: 'Hitung dan Terapkan', + invalidResolution: 'Masukkan resolusi asli yang valid setelah video siap.', + select: 'Pilih Area', + clear: 'Pulihkan Otomatis', + saveFailed: 'Gagal menyimpan area input.', + tooSmall: 'Area yang dipilih terlalu kecil.', + previewUnavailable: 'Pratinjau tidak tersedia', + clearConfirm: 'Pulihkan deteksi batas hitam otomatis?', + dragHint: 'Seret untuk memilih area desktop jarak jauh', + finish: 'Selesai', + confirm: 'Konfirmasi', + cancel: 'Batal' + }, auto: 'Otomatis', autoTips: 'Tearing layar atau offset tetikus dapat terjadi pada resolusi tertentu. Pertimbangkan untuk menyesuaikan resolusi host jarak jauh atau menonaktifkan mode otomatis.', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 921947a..1e419ad 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -66,6 +66,36 @@ const it = { videoDirectTips: 'Abilita HTTPS in "Impostazioni > Dispositivo" per utilizzare questa modalità', resolution: 'Risoluzione', + controlRegion: { + title: 'Calibrazione del mouse', + description: + 'Utilizza questa impostazione quando il dispositivo controllato usa una risoluzione diversa da 16:9 e il cursore risulta disallineato orizzontalmente o verticalmente.', + off: 'Disattivata', + auto: 'Automatica', + autoWarning: + "La calibrazione potrebbe non riuscire se l'applicazione utente ha uno sfondo completamente nero.", + manual: 'Manuale', + originalResolution: 'Risoluzione originale', + selectResolution: 'Seleziona la risoluzione originale', + useSelectedArea: "Usa la risoluzione dell'area selezionata", + addResolution: 'Aggiungi una risoluzione personalizzata', + add: 'Aggiungi', + duplicateResolution: 'Questa risoluzione esiste già.', + width: 'Larghezza', + height: 'Altezza', + apply: 'Calcola e applica', + invalidResolution: 'Inserisci una risoluzione originale valida quando il video è pronto.', + select: 'Seleziona area', + clear: 'Ripristina il rilevamento automatico', + saveFailed: "Impossibile salvare l'area di input.", + tooSmall: "L'area selezionata è troppo piccola.", + previewUnavailable: 'Anteprima non disponibile', + clearConfirm: 'Ripristinare il rilevamento automatico dei bordi neri?', + dragHint: "Trascina per selezionare l'area del desktop remoto", + finish: 'Fine', + confirm: 'Conferma', + cancel: 'Annulla' + }, auto: 'Automatico', autoTips: 'Potrebbero verificarsi tearing dello schermo o spostamento del mouse a risoluzioni specifiche. Considera di regolare la risoluzione del dispositivo remoto o disabilitare la modalità automatica.', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 50e11f4..c305acd 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -64,6 +64,36 @@ const ja = { video: 'ビデオモード', videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください', resolution: '解像度', + controlRegion: { + title: 'マウス位置補正', + description: + '操作対象のデバイスが 16:9 以外の解像度を使用していて、カーソルの位置が水平方向または垂直方向にずれる場合に使用します。', + off: 'オフ', + auto: '自動', + autoWarning: + 'ユーザーアプリケーションの背景が完全な黒の場合、補正に失敗することがあります。', + manual: '手動', + originalResolution: '元の解像度', + selectResolution: '元の解像度を選択', + useSelectedArea: '選択領域の解像度を使用', + addResolution: 'カスタム解像度を追加', + add: '追加', + duplicateResolution: 'この解像度はすでに存在します。', + width: '幅', + height: '高さ', + apply: '計算して適用', + invalidResolution: 'ビデオの準備完了後、有効な元の解像度を入力してください。', + select: '領域を選択', + clear: '自動に戻す', + saveFailed: '入力領域を保存できませんでした。', + tooSmall: '選択した領域が小さすぎます。', + previewUnavailable: 'プレビューを利用できません', + clearConfirm: '黒帯の自動検出に戻しますか?', + dragHint: 'ドラッグしてリモートデスクトップの領域を選択', + finish: '完了', + confirm: '確認', + cancel: 'キャンセル' + }, auto: '自動', autoTips: '特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 132b322..e1b2d61 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -63,6 +63,35 @@ const ko = { video: '비디오 모드', videoDirectTips: '이 모드를 사용하려면 "설정 > 장치"에서 HTTPS를 활성화하세요', resolution: '해상도', + controlRegion: { + title: '마우스 보정', + description: + '제어 대상 장치가 16:9가 아닌 해상도를 사용하며 커서가 가로 또는 세로 방향으로 어긋날 때 이 설정을 사용하세요.', + off: '끄기', + auto: '자동', + autoWarning: '사용자 애플리케이션의 배경이 완전히 검은색이면 보정에 실패할 수 있습니다.', + manual: '수동', + originalResolution: '원본 해상도', + selectResolution: '원본 해상도 선택', + useSelectedArea: '선택한 영역의 해상도 사용', + addResolution: '사용자 지정 해상도 추가', + add: '추가', + duplicateResolution: '이미 존재하는 해상도입니다.', + width: '너비', + height: '높이', + apply: '계산 후 적용', + invalidResolution: '비디오가 준비되면 올바른 원본 해상도를 입력하세요.', + select: '영역 선택', + clear: '자동으로 복원', + saveFailed: '입력 영역을 저장하지 못했습니다.', + tooSmall: '선택한 영역이 너무 작습니다.', + previewUnavailable: '미리보기를 사용할 수 없습니다', + clearConfirm: '자동 검은색 테두리 감지로 복원하시겠습니까?', + dragHint: '드래그하여 원격 데스크톱 영역을 선택하세요', + finish: '완료', + confirm: '확인', + cancel: '취소' + }, auto: '자동 설정', autoTips: '일부 해상도에서는 화면이 왜곡되거나 마우스 동작이 비정상적으로 나타날 수 있습니다. 원격 컴퓨터의 해상도를 변경하거나 자동 설정 대신 수동 설정을 사용해 보세요.', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 3d6f3b3..cad7c37 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -65,6 +65,36 @@ const nb = { video: 'Video-kodek', videoDirectTips: 'Aktiver HTTPS i "Innstillinger > Enhet" for å bruke denne modusen', resolution: 'Oppløsning', + controlRegion: { + title: 'Musekalibrering', + description: + 'Bruk denne innstillingen når den kontrollerte enheten bruker en oppløsning som ikke er 16:9, og markøren er forskjøvet vannrett eller loddrett.', + off: 'Av', + auto: 'Automatisk', + autoWarning: + 'Kalibreringen kan mislykkes hvis brukerprogrammet har en helt svart bakgrunn.', + manual: 'Manuell', + originalResolution: 'Opprinnelig oppløsning', + selectResolution: 'Velg opprinnelig oppløsning', + useSelectedArea: 'Bruk oppløsningen til det valgte området', + addResolution: 'Legg til egendefinert oppløsning', + add: 'Legg til', + duplicateResolution: 'Denne oppløsningen finnes allerede.', + width: 'Bredde', + height: 'Høyde', + apply: 'Beregn og bruk', + invalidResolution: 'Angi en gyldig opprinnelig oppløsning når videoen er klar.', + select: 'Velg område', + clear: 'Gjenopprett automatisk registrering', + saveFailed: 'Kunne ikke lagre inndataområdet.', + tooSmall: 'Det valgte området er for lite.', + previewUnavailable: 'Forhåndsvisning er utilgjengelig', + clearConfirm: 'Gjenopprette automatisk registrering av svarte kanter?', + dragHint: 'Dra for å velge området på det eksterne skrivebordet', + finish: 'Ferdig', + confirm: 'Bekreft', + cancel: 'Avbryt' + }, auto: 'Automatisk', autoTips: 'Skjermriving eller peker-forskyvning kan oppstå ved enkelte oppløsninger. Prøv å justere den eksterne vertens oppløsning eller skru av automatisk modus.', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index ab37893..37e22d6 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -66,6 +66,37 @@ const nl = { video: 'Videomodus', videoDirectTips: 'Schakel HTTPS in "Instellingen > Apparaat" in om deze modus te gebruiken', resolution: 'Resolutie', + controlRegion: { + title: 'Muiskalibratie', + description: + 'Gebruik deze instelling wanneer het bestuurde apparaat een andere resolutie dan 16:9 gebruikt en de cursor horizontaal of verticaal niet goed is uitgelijnd.', + off: 'Uit', + auto: 'Automatisch', + autoWarning: + 'De kalibratie kan mislukken als de gebruikerstoepassing een volledig zwarte achtergrond heeft.', + manual: 'Handmatig', + originalResolution: 'Oorspronkelijke resolutie', + selectResolution: 'Oorspronkelijke resolutie selecteren', + useSelectedArea: 'Resolutie van geselecteerd gebied gebruiken', + addResolution: 'Aangepaste resolutie toevoegen', + add: 'Toevoegen', + duplicateResolution: 'Deze resolutie bestaat al.', + width: 'Breedte', + height: 'Hoogte', + apply: 'Berekenen en toepassen', + invalidResolution: + 'Voer een geldige oorspronkelijke resolutie in zodra de video gereed is.', + select: 'Gebied selecteren', + clear: 'Automatische detectie herstellen', + saveFailed: 'Het invoergebied kan niet worden opgeslagen.', + tooSmall: 'Het geselecteerde gebied is te klein.', + previewUnavailable: 'Voorbeeld niet beschikbaar', + clearConfirm: 'Automatische detectie van zwarte randen herstellen?', + dragHint: 'Sleep om het externe bureaubladgebied te selecteren', + finish: 'Gereed', + confirm: 'Bevestigen', + cancel: 'Annuleren' + }, auto: 'Automatisch', autoTips: 'Bij bepaalde resoluties kunnen schermverscheuringen of muisverplaatsingen optreden. Overweeg de resolutie van de externe host aan te passen of schakel de automatische modus uit.', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index dc848da..514e07e 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -65,6 +65,36 @@ const pl = { video: 'Tryb wideo', videoDirectTips: 'Włącz HTTPS w „Ustawienia > Urządzenie”, aby korzystać z tego trybu', resolution: 'Rozdzielczość', + controlRegion: { + title: 'Kalibracja myszy', + description: + 'Użyj tego ustawienia, gdy kontrolowane urządzenie korzysta z rozdzielczości innej niż 16:9, a kursor jest przesunięty w poziomie lub w pionie.', + off: 'Wyłączona', + auto: 'Automatyczna', + autoWarning: + 'Kalibracja może się nie powieść, gdy aplikacja użytkownika ma całkowicie czarne tło.', + manual: 'Ręczna', + originalResolution: 'Oryginalna rozdzielczość', + selectResolution: 'Wybierz oryginalną rozdzielczość', + useSelectedArea: 'Użyj rozdzielczości zaznaczonego obszaru', + addResolution: 'Dodaj niestandardową rozdzielczość', + add: 'Dodaj', + duplicateResolution: 'Ta rozdzielczość już istnieje.', + width: 'Szerokość', + height: 'Wysokość', + apply: 'Oblicz i zastosuj', + invalidResolution: 'Po uruchomieniu wideo wprowadź prawidłową oryginalną rozdzielczość.', + select: 'Zaznacz obszar', + clear: 'Przywróć automatyczne wykrywanie', + saveFailed: 'Nie udało się zapisać obszaru wejściowego.', + tooSmall: 'Zaznaczony obszar jest zbyt mały.', + previewUnavailable: 'Podgląd niedostępny', + clearConfirm: 'Przywrócić automatyczne wykrywanie czarnych obramowań?', + dragHint: 'Przeciągnij, aby zaznaczyć obszar pulpitu zdalnego', + finish: 'Gotowe', + confirm: 'Potwierdź', + cancel: 'Anuluj' + }, auto: 'Automatyczny', autoTips: 'W określonych rozdzielczościach może wystąpić rozrywanie ekranu lub przesunięcie myszy. Rozważ dostosowanie rozdzielczości zdalnego hosta lub wyłącz tryb automatyczny.', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 00bac05..a99433f 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -64,6 +64,36 @@ const pt_br = { video: 'Modo de Vídeo', videoDirectTips: 'Ative HTTPS em "Configurações > Dispositivo" para usar este modo', resolution: 'Resolução', + controlRegion: { + title: 'Calibração do mouse', + description: + 'Use esta configuração quando o dispositivo controlado usar uma resolução diferente de 16:9 e o cursor estiver desalinhado horizontal ou verticalmente.', + off: 'Desativado', + auto: 'Automático', + autoWarning: + 'A calibração pode falhar se o aplicativo do usuário tiver um fundo totalmente preto.', + manual: 'Manual', + originalResolution: 'Resolução original', + selectResolution: 'Selecionar resolução original', + useSelectedArea: 'Usar a resolução da área selecionada', + addResolution: 'Adicionar resolução personalizada', + add: 'Adicionar', + duplicateResolution: 'Esta resolução já existe.', + width: 'Largura', + height: 'Altura', + apply: 'Calcular e aplicar', + invalidResolution: 'Insira uma resolução original válida quando o vídeo estiver pronto.', + select: 'Selecionar área', + clear: 'Restaurar detecção automática', + saveFailed: 'Falha ao salvar a área de entrada.', + tooSmall: 'A área selecionada é muito pequena.', + previewUnavailable: 'Pré-visualização indisponível', + clearConfirm: 'Restaurar a detecção automática de bordas pretas?', + dragHint: 'Arraste para selecionar a área da área de trabalho remota', + finish: 'Concluir', + confirm: 'Confirmar', + cancel: 'Cancelar' + }, auto: 'Automático', autoTips: 'Rasgos na tela ou desvio do mouse podem ocorrer em resoluções específicas. Considere ajustar a resolução do host remoto ou desativar o modo automático.', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index a676683..2fdb7b2 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -64,6 +64,36 @@ const ru = { video: 'Видеорежим', videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим', resolution: 'Разрешение', + controlRegion: { + title: 'Калибровка мыши', + description: + 'Используйте эту настройку, если управляемое устройство использует разрешение с соотношением сторон, отличным от 16:9, и курсор смещён по горизонтали или вертикали.', + off: 'Выкл.', + auto: 'Автоматически', + autoWarning: + 'Калибровка может завершиться неудачей, если фон пользовательского приложения полностью чёрный.', + manual: 'Вручную', + originalResolution: 'Исходное разрешение', + selectResolution: 'Выберите исходное разрешение', + useSelectedArea: 'Использовать разрешение выбранной области', + addResolution: 'Добавить пользовательское разрешение', + add: 'Добавить', + duplicateResolution: 'Такое разрешение уже существует.', + width: 'Ширина', + height: 'Высота', + apply: 'Рассчитать и применить', + invalidResolution: 'Введите допустимое исходное разрешение после появления видео.', + select: 'Выбрать область', + clear: 'Восстановить автоматически', + saveFailed: 'Не удалось сохранить область ввода.', + tooSmall: 'Выбранная область слишком мала.', + previewUnavailable: 'Предварительный просмотр недоступен', + clearConfirm: 'Восстановить автоматическое обнаружение чёрных полей?', + dragHint: 'Перетащите, чтобы выбрать область удалённого рабочего стола', + finish: 'Готово', + confirm: 'Подтвердить', + cancel: 'Отмена' + }, auto: 'Автоматическое', autoTips: 'При некоторых разрешениях экрана могут возникать артефакты изображения или смещение курсора. Пожалуйста, настройте разрешение удаленного компьютера или отключите автоматический режим для передачи видеопотока.', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 90d58ba..2e07934 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -62,6 +62,35 @@ const se = { video: 'Videoläge', videoDirectTips: 'Aktivera HTTPS i "Inställningar > Enhet" för att använda detta läge', resolution: 'Upplösning', + controlRegion: { + title: 'Muskalibrering', + description: + 'Använd den här inställningen när den styrda enheten använder en upplösning som inte är 16:9 och markören är feljusterad i sid- eller höjdled.', + off: 'Av', + auto: 'Automatisk', + autoWarning: 'Kalibreringen kan misslyckas om användarprogrammet har en helsvart bakgrund.', + manual: 'Manuell', + originalResolution: 'Ursprunglig upplösning', + selectResolution: 'Välj ursprunglig upplösning', + useSelectedArea: 'Använd upplösningen för det valda området', + addResolution: 'Lägg till anpassad upplösning', + add: 'Lägg till', + duplicateResolution: 'Den här upplösningen finns redan.', + width: 'Bredd', + height: 'Höjd', + apply: 'Beräkna och tillämpa', + invalidResolution: 'Ange en giltig ursprunglig upplösning när videon är klar.', + select: 'Välj område', + clear: 'Återställ automatiskt', + saveFailed: 'Det gick inte att spara inmatningsområdet.', + tooSmall: 'Det valda området är för litet.', + previewUnavailable: 'Förhandsvisning är inte tillgänglig', + clearConfirm: 'Återställa automatisk identifiering av svarta kanter?', + dragHint: 'Dra för att välja området för fjärrskrivbordet', + finish: 'Klar', + confirm: 'Bekräfta', + cancel: 'Avbryt' + }, auto: 'Automatisk', autoTips: 'Skärmtear eller musförskjutning kan förekomma vid vissa upplösningar. Överväg att justera fjärrvärdens upplösning eller inaktivera automatiskt läge.', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index 7d7a9ec..929f2a9 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -62,6 +62,35 @@ const th = { video: 'โหมดวีดีโอ', videoDirectTips: 'เปิดใช้งาน HTTPS ใน "การตั้งค่า > อุปกรณ์" เพื่อใช้โหมดนี้', resolution: 'ความคมชัด', + controlRegion: { + title: 'ปรับเทียบเมาส์', + description: + 'ใช้การตั้งค่านี้เมื่ออุปกรณ์ที่ควบคุมใช้ความละเอียดที่ไม่ใช่ 16:9 และเคอร์เซอร์คลาดเคลื่อนในแนวนอนหรือแนวตั้ง', + off: 'ปิด', + auto: 'อัตโนมัติ', + autoWarning: 'การปรับเทียบอาจล้มเหลวเมื่อแอปพลิเคชันของผู้ใช้มีพื้นหลังสีดำสนิท', + manual: 'กำหนดเอง', + originalResolution: 'ความละเอียดต้นฉบับ', + selectResolution: 'เลือกความละเอียดต้นฉบับ', + useSelectedArea: 'ใช้ความละเอียดของพื้นที่ที่เลือก', + addResolution: 'เพิ่มความละเอียดแบบกำหนดเอง', + add: 'เพิ่ม', + duplicateResolution: 'มีความละเอียดนี้อยู่แล้ว', + width: 'ความกว้าง', + height: 'ความสูง', + apply: 'คำนวณและนำไปใช้', + invalidResolution: 'ป้อนความละเอียดต้นฉบับที่ถูกต้องหลังจากวิดีโอพร้อมใช้งาน', + select: 'เลือกพื้นที่', + clear: 'คืนค่าอัตโนมัติ', + saveFailed: 'บันทึกพื้นที่อินพุตไม่สำเร็จ', + tooSmall: 'พื้นที่ที่เลือกมีขนาดเล็กเกินไป', + previewUnavailable: 'ไม่สามารถดูตัวอย่างได้', + clearConfirm: 'คืนค่าการตรวจจับขอบสีดำอัตโนมัติหรือไม่', + dragHint: 'ลากเพื่อเลือกพื้นที่เดสก์ท็อประยะไกล', + finish: 'เสร็จสิ้น', + confirm: 'ยืนยัน', + cancel: 'ยกเลิก' + }, auto: 'อัตโนมัติ', autoTips: 'อาการภาพฉีกขาดหรือเมาส์ไม่ตรงตำแหน่งอาจเกิดขึ้นที่ความละเอียดบางระดับ แนะนำให้ปรับความละเอียดของคอมพิวเตอร์ต้นทางหรือปิดโหมดอัตโนมัติ', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 3a8c4cd..80abb96 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -65,6 +65,36 @@ const tr = { video: 'Görüntü modu', videoDirectTips: 'kullanmak için "Ayarlar > Cihaz" HTTPS aktif edin', resolution: 'Çözünürlük', + controlRegion: { + title: 'Fare Kalibrasyonu', + description: + 'Kontrol edilen cihaz 16:9 dışında bir çözünürlük kullandığında ve imleç yatay veya dikey olarak hizalanmadığında bu ayarı kullanın.', + off: 'Kapalı', + auto: 'Otomatik', + autoWarning: + 'Kullanıcı uygulamasının arka planı tamamen siyah olduğunda kalibrasyon başarısız olabilir.', + manual: 'Manuel', + originalResolution: 'Orijinal Çözünürlük', + selectResolution: 'Orijinal çözünürlüğü seçin', + useSelectedArea: 'Seçili alanın çözünürlüğünü kullan', + addResolution: 'Özel çözünürlük ekle', + add: 'Ekle', + duplicateResolution: 'Bu çözünürlük zaten mevcut.', + width: 'Genişlik', + height: 'Yükseklik', + apply: 'Hesapla ve Uygula', + invalidResolution: 'Video hazır olduktan sonra geçerli bir orijinal çözünürlük girin.', + select: 'Alan Seç', + clear: 'Otomatik Ayarı Geri Yükle', + saveFailed: 'Giriş alanı kaydedilemedi.', + tooSmall: 'Seçili alan çok küçük.', + previewUnavailable: 'Önizleme kullanılamıyor', + clearConfirm: 'Otomatik siyah kenar algılama geri yüklensin mi?', + dragHint: 'Uzak masaüstü alanını seçmek için sürükleyin', + finish: 'Bitti', + confirm: 'Onayla', + cancel: 'İptal' + }, auto: 'Otomatik', autoTips: 'Belirli çözünürlüklerde ekran yırtılması veya fare kayması meydana gelebilir. Bu durumda uzak ana bilgisayarın çözünürlüğünü ayarlamayı ya da otomatik modu devre dışı bırakmayı deneyin.', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 593b666..10e3706 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -65,6 +65,36 @@ const uk = { video: 'Відеорежим', videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим', resolution: 'Роздільна здатність', + controlRegion: { + title: 'Калібрування миші', + description: + 'Використовуйте це налаштування, якщо керований пристрій має роздільну здатність зі співвідношенням сторін, відмінним від 16:9, а курсор зміщений по горизонталі або вертикалі.', + off: 'Вимкнено', + auto: 'Автоматично', + autoWarning: + 'Калібрування може завершитися невдало, якщо користувацька програма має повністю чорне тло.', + manual: 'Вручну', + originalResolution: 'Початкова роздільна здатність', + selectResolution: 'Виберіть початкову роздільну здатність', + useSelectedArea: 'Використати роздільну здатність вибраної області', + addResolution: 'Додати власну роздільну здатність', + add: 'Додати', + duplicateResolution: 'Така роздільна здатність уже існує.', + width: 'Ширина', + height: 'Висота', + apply: 'Обчислити й застосувати', + invalidResolution: 'Введіть дійсну початкову роздільну здатність після появи відео.', + select: 'Вибрати область', + clear: 'Відновити автоматично', + saveFailed: 'Не вдалося зберегти область введення.', + tooSmall: 'Вибрана область замала.', + previewUnavailable: 'Попередній перегляд недоступний', + clearConfirm: 'Відновити автоматичне виявлення чорних полів?', + dragHint: 'Перетягніть, щоб вибрати область віддаленого робочого столу', + finish: 'Готово', + confirm: 'Підтвердити', + cancel: 'Скасувати' + }, auto: 'Автоматично', autoTips: 'Може виникнути розрив зображення або зміщення миші при певних роздільних здатностях. Розгляньте можливість налаштування роздільної здатності віддаленого хоста або вимкнення автоматичного режиму для передачі відеопотоку.', diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index f2314c7..0ca2f1d 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -64,6 +64,36 @@ const vi = { video: 'Chế độ video', videoDirectTips: 'Bật HTTPS trong "Cài đặt > Thiết bị" để sử dụng chế độ này', resolution: 'Độ phân giải', + controlRegion: { + title: 'Hiệu chỉnh chuột', + description: + 'Sử dụng cài đặt này khi thiết bị được điều khiển dùng độ phân giải không phải tỷ lệ 16:9 và con trỏ bị lệch theo chiều ngang hoặc chiều dọc.', + off: 'Tắt', + auto: 'Tự động', + autoWarning: + 'Hiệu chỉnh có thể thất bại khi ứng dụng của người dùng có nền hoàn toàn màu đen.', + manual: 'Thủ công', + originalResolution: 'Độ phân giải gốc', + selectResolution: 'Chọn độ phân giải gốc', + useSelectedArea: 'Sử dụng độ phân giải của vùng đã chọn', + addResolution: 'Thêm độ phân giải tùy chỉnh', + add: 'Thêm', + duplicateResolution: 'Độ phân giải này đã tồn tại.', + width: 'Chiều rộng', + height: 'Chiều cao', + apply: 'Tính toán và áp dụng', + invalidResolution: 'Nhập độ phân giải gốc hợp lệ sau khi video sẵn sàng.', + select: 'Chọn vùng', + clear: 'Khôi phục tự động', + saveFailed: 'Không thể lưu vùng đầu vào.', + tooSmall: 'Vùng đã chọn quá nhỏ.', + previewUnavailable: 'Không thể xem trước', + clearConfirm: 'Khôi phục tính năng tự động phát hiện viền đen?', + dragHint: 'Kéo để chọn vùng màn hình từ xa', + finish: 'Xong', + confirm: 'Xác nhận', + cancel: 'Hủy' + }, auto: 'Tự động', autoTips: 'Màn hình bị xé hoặc lệch chuột có thể xảy ra ở các độ phân giải nhất định. Hãy xem xét điều chỉnh độ phân giải của máy remote hoặc tắt chế độ tự động.', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 7c5e1d7..7cce56a 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -64,6 +64,35 @@ const zh = { video: '视频模式', videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启', resolution: '分辨率', + controlRegion: { + title: '鼠标校准', + description: + '当受控设备为非 16:9 分辨率,且你发现光标在水平/垂直方向上不同步,则使用该设置。', + off: '关闭', + auto: '自动', + autoWarning: '若用户程序为纯黑色背景,则可能校准出错。', + manual: '手动', + originalResolution: '原始分辨率', + selectResolution: '选择原始分辨率', + useSelectedArea: '使用框选分辨率', + addResolution: '添加自定义分辨率', + add: '添加', + duplicateResolution: '该分辨率已存在。', + width: '宽度', + height: '高度', + apply: '换算并应用', + invalidResolution: '请在视频准备就绪后输入有效的原始分辨率。', + select: '框选区域', + clear: '恢复自动裁切', + saveFailed: '控制区域保存失败。', + tooSmall: '选择区域过小。', + previewUnavailable: '暂时无法预览', + clearConfirm: '恢复自动黑边检测?', + dragHint: '拖拽框选远程桌面区域', + finish: '完成', + confirm: '确定', + cancel: '取消' + }, auto: '自动', autoTips: '在某些分辨率下可能存在花屏或鼠标偏移的情况,请调整远程主机分辨率或者不使用自动模式。', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index c570eef..8176c8f 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -61,6 +61,35 @@ const zh_tw = { video: '編碼格式', videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟', resolution: '解析度', + controlRegion: { + title: '滑鼠校正', + description: + '當受控裝置使用非 16:9 解析度,且游標在水平或垂直方向出現偏移時,請使用此設定。', + off: '關閉', + auto: '自動', + autoWarning: '當使用者應用程式使用全黑背景時,校正可能會失敗。', + manual: '手動', + originalResolution: '原始解析度', + selectResolution: '選擇原始解析度', + useSelectedArea: '使用所選區域的解析度', + addResolution: '新增自訂解析度', + add: '新增', + duplicateResolution: '此解析度已存在。', + width: '寬度', + height: '高度', + apply: '計算並套用', + invalidResolution: '請在影片準備完成後輸入有效的原始解析度。', + select: '選擇區域', + clear: '恢復自動偵測', + saveFailed: '無法儲存輸入區域。', + tooSmall: '所選區域太小。', + previewUnavailable: '無法預覽', + clearConfirm: '要恢復自動黑邊偵測嗎?', + dragHint: '拖曳以選擇遠端桌面區域', + finish: '完成', + confirm: '確認', + cancel: '取消' + }, auto: '自動', autoTips: '在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。', diff --git a/web/src/jotai/screen.ts b/web/src/jotai/screen.ts index a385c5b..d5b26ba 100644 --- a/web/src/jotai/screen.ts +++ b/web/src/jotai/screen.ts @@ -1,6 +1,6 @@ import { atom } from 'jotai'; -import { Resolution } from '@/types'; +import { ControlRegionMode, InputRegion, Resolution } from '@/types'; export const isHdmiEnabledAtom = atom(true); @@ -14,3 +14,14 @@ export const videoScaleAtom = atom(1.0); // browser screen resolution export const resolutionAtom = atom(null); + +// currently effective absolute mouse input region +export const inputRegionAtom = atom(null); +export const manualInputRegionAtom = atom(null); +export const selectedOriginalResolutionAtom = atom(''); + +// device-level control region mode; disabled by default +export const controlRegionModeAtom = atom('off'); + +// show the live input-region selection overlay +export const inputRegionSelectingAtom = atom(false); diff --git a/web/src/jotai/settings.ts b/web/src/jotai/settings.ts index e59d9be..4d6900d 100644 --- a/web/src/jotai/settings.ts +++ b/web/src/jotai/settings.ts @@ -6,6 +6,9 @@ export const menuDisabledItemsAtom = atom([]); // track how many submenus are currently open export const submenuOpenCountAtom = atom(0); +// signal all toolbar menus to close +export const menuCloseSignalAtom = atom(0); + // web title export const webTitleAtom = atom(''); diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index a06337a..a533a52 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -1,13 +1,22 @@ import { useEffect, useState } from 'react'; import { Splitter } from 'antd'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import { useMediaQuery } from 'react-responsive'; +import { getInputRegion, setControlRegionMode } from '@/api/vm.ts'; +import { ControlRegionConfig, InputRegion } from '@/types'; import * as storage from '@/lib/localstorage.ts'; import { client } from '@/lib/websocket.ts'; import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts'; -import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts'; +import { + controlRegionModeAtom, + inputRegionAtom, + manualInputRegionAtom, + resolutionAtom, + selectedOriginalResolutionAtom, + videoModeAtom +} from '@/jotai/screen.ts'; import { Head } from '@/components/head.tsx'; import { CaptureStatusOverlay, useCaptureStatus } from './capture-status'; @@ -18,6 +27,15 @@ import { H264ModeNotification, Notification } from './notification.tsx'; import { Sidebar as PicoclawSidebar } from './picoclaw'; import { ActionOverlay } from './picoclaw/action-overlay.tsx'; import { Screen } from './screen'; +import { AutoRegion } from './screen/auto-region.tsx'; +import { + getMediaSize, + isInputRegionCompatible, + isMediaReady, + isValidInputRegion +} from './screen/geometry.ts'; +import { InputRegionOverlay } from './screen/input-region-overlay.tsx'; +import { ManualRegion } from './screen/manual-region.tsx'; import { VirtualKeyboard } from './virtual-keyboard'; function getVideoMode() { @@ -43,6 +61,10 @@ export const Desktop = () => { const [videoMode, setVideoMode] = useAtom(videoModeAtom); const [resolution, setResolution] = useAtom(resolutionAtom); + const [inputRegion, setInputRegion] = useAtom(inputRegionAtom); + const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom); + const setManualInputRegion = useSetAtom(manualInputRegionAtom); + const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom); const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom); useEffect(() => { @@ -52,11 +74,113 @@ export const Desktop = () => { const res = storage.getResolution() || { width: 0, height: 0 }; setResolution(res); + setInputRegion(null); + setManualInputRegion(null); + setSelectedOriginalResolution(''); + setControlRegionModeState('off'); + + getInputRegion() + .then((rsp) => { + const config = rsp.data as ControlRegionConfig | null; + const mode = config?.mode || 'off'; + const manualRegion = isValidInputRegion(config as InputRegion) + ? (config as InputRegion) + : null; + const selectedResolution = config?.selectedResolution || ''; + setManualInputRegion(manualRegion); + setSelectedOriginalResolution(selectedResolution); + setInputRegion(mode === 'manual' && !selectedResolution ? manualRegion : null); + setControlRegionModeState(mode); + }) + .catch(() => { + setControlRegionModeState('off'); + setInputRegion(null); + setManualInputRegion(null); + setSelectedOriginalResolution(''); + }); return () => { client.close(); }; - }, [activeVideoMode, setResolution, setVideoMode]); + }, [ + activeVideoMode, + setControlRegionModeState, + setInputRegion, + setManualInputRegion, + setResolution, + setSelectedOriginalResolution, + setVideoMode + ]); + + useEffect(() => { + if (controlRegionMode !== 'manual' || !inputRegion) { + return; + } + + const screen = document.getElementById('screen'); + if (!screen) { + return; + } + const target = screen; + const region = inputRegion; + + let cleared = false; + let validationTimer: ReturnType | null = null; + function validateMediaSize() { + if (cleared) { + return; + } + + if (validationTimer !== null) { + clearTimeout(validationTimer); + } + validationTimer = setTimeout(() => { + const mediaSize = getMediaSize(target); + if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) { + return; + } + + cleared = true; + setControlRegionMode('off') + .then((rsp) => { + if (rsp.code === 0) { + setControlRegionModeState('off'); + setInputRegion(null); + } + }) + .catch(() => undefined); + }, 300); + } + + validateMediaSize(); + const observer = new MutationObserver(validateMediaSize); + observer.observe(target, { + attributes: true, + attributeFilter: ['data-media-width', 'data-media-height'] + }); + target.addEventListener('load', validateMediaSize); + target.addEventListener('loadedmetadata', validateMediaSize); + target.addEventListener('canplay', validateMediaSize); + target.addEventListener('resize', validateMediaSize); + + return () => { + observer.disconnect(); + if (validationTimer !== null) { + clearTimeout(validationTimer); + } + target.removeEventListener('load', validateMediaSize); + target.removeEventListener('loadedmetadata', validateMediaSize); + target.removeEventListener('canplay', validateMediaSize); + target.removeEventListener('resize', validateMediaSize); + }; + }, [ + controlRegionMode, + inputRegion, + resolution, + setControlRegionModeState, + setInputRegion, + videoMode + ]); function handleSplitterResize(sizes: number[]) { const nextSidebarWidth = sizes[1]; @@ -98,6 +222,9 @@ export const Desktop = () => { + + + diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx index 368cee9..f50ca28 100644 --- a/web/src/pages/desktop/menu/download.tsx +++ b/web/src/pages/desktop/menu/download.tsx @@ -60,7 +60,8 @@ export const DownloadImage = () => { // Keep monitoring an active remote download after the popover closes so // completion can still refresh an already-open image list. - if (!remoteDownloadActive.current) { + const transferActive = remoteDownloadActive.current || fileUploadActive.current; + if (!transferActive) { setInput(''); setSha256sum(''); setStatus(''); diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index e7859f7..570b227 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useAuth } from '@/contexts/auth.ts'; import { Divider } from 'antd'; import clsx from 'clsx'; @@ -6,7 +6,11 @@ import { useAtomValue } from 'jotai'; import { GripVerticalIcon } from 'lucide-react'; import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; -import { keyboardLedStatusVisibleAtom, menuDisabledItemsAtom } from '@/jotai/settings.ts'; +import { + keyboardLedStatusVisibleAtom, + menuCloseSignalAtom, + menuDisabledItemsAtom +} from '@/jotai/settings.ts'; import { useMenuBounds } from '@/hooks/useMenuBounds.ts'; import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts'; @@ -31,6 +35,7 @@ export const Menu = () => { const isAdmin = account.role === 'admin'; const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); + const menuCloseSignal = useAtomValue(menuCloseSignalAtom); const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom); const { @@ -44,6 +49,12 @@ export const Menu = () => { const menuBounds = useMenuBounds(nodeRef, isMenuExpanded); + useEffect(() => { + if (menuCloseSignal > 0) { + setIsMenuExpanded(false); + } + }, [menuCloseSignal, setIsMenuExpanded]); + function onDragStop(_e: DraggableEvent, data: DraggableData) { if (data.x === 0 && data.y === 0) return; handleMoved(); diff --git a/web/src/pages/desktop/menu/mouse/index.tsx b/web/src/pages/desktop/menu/mouse/index.tsx index feb59f1..d6a651f 100644 --- a/web/src/pages/desktop/menu/mouse/index.tsx +++ b/web/src/pages/desktop/menu/mouse/index.tsx @@ -13,6 +13,7 @@ import { } from '@/jotai/mouse'; import { MenuItem } from '@/components/menu-item.tsx'; +import { OriginalResolution } from '../screen/original-resolution.tsx'; import { Cursor } from './cursor.tsx'; import { Direction } from './direction.tsx'; import { HidMode } from './hid-mode.tsx'; @@ -58,6 +59,7 @@ export const Mouse = () => { + diff --git a/web/src/pages/desktop/menu/screen/original-resolution.tsx b/web/src/pages/desktop/menu/screen/original-resolution.tsx new file mode 100644 index 0000000..bcad02b --- /dev/null +++ b/web/src/pages/desktop/menu/screen/original-resolution.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState } from 'react'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { + Button, + InputNumber, + message, + Modal, + Popover, + Segmented, + Select, + Space, + Tooltip, + Typography +} from 'antd'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { ScanSearchIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { + getInputRegion, + setControlRegionMode, + setOriginalResolutionConfig, + setSelectedOriginalResolution +} from '@/api/vm.ts'; +import { + ControlRegionConfig, + ControlRegionMode, + InputRegion, + OriginalResolution as ResolutionPreset +} from '@/types'; +import { keyboardLockAtom } from '@/jotai/keyboard.ts'; +import { + controlRegionModeAtom, + inputRegionAtom, + inputRegionSelectingAtom, + manualInputRegionAtom, + selectedOriginalResolutionAtom +} from '@/jotai/screen.ts'; +import { menuCloseSignalAtom } from '@/jotai/settings.ts'; +import { isValidInputRegion } from '@/pages/desktop/screen/geometry.ts'; + +const resolutionKey = ({ width, height }: ResolutionPreset) => `${width}x${height}`; + +export const OriginalResolution = () => { + const { t } = useTranslation(); + const setInputRegion = useSetAtom(inputRegionAtom); + const manualInputRegion = useAtomValue(manualInputRegionAtom); + const setManualInputRegion = useSetAtom(manualInputRegionAtom); + const [selectedResolution, setSelectedResolution] = useAtom(selectedOriginalResolutionAtom); + const [mode, setMode] = useAtom(controlRegionModeAtom); + const setSelecting = useSetAtom(inputRegionSelectingAtom); + const setKeyboardLock = useSetAtom(keyboardLockAtom); + const requestMenuClose = useSetAtom(menuCloseSignalAtom); + const [messageApi, contextHolder] = message.useMessage(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isAddOpen, setIsAddOpen] = useState(false); + const [resolutions, setResolutions] = useState([]); + const [newWidth, setNewWidth] = useState(null); + const [newHeight, setNewHeight] = useState(null); + + useEffect(() => { + return () => { + setKeyboardLock({ source: 'control-region-popover', locked: false }); + setKeyboardLock({ source: 'control-region-resolution-modal', locked: false }); + }; + }, [setKeyboardLock]); + + useEffect(() => { + setKeyboardLock({ source: 'control-region-resolution-modal', locked: isAddOpen }); + }, [isAddOpen, setKeyboardLock]); + + async function loadConfig() { + const rsp = await getInputRegion(); + if (rsp.code === 0) { + const config = rsp.data as ControlRegionConfig; + setResolutions(config?.resolutions || []); + setSelectedResolution(config?.selectedResolution || ''); + setManualInputRegion( + isValidInputRegion(config as InputRegion) ? (config as InputRegion) : null + ); + } + return rsp; + } + + async function applyResolution(key: string) { + const rsp = await setSelectedOriginalResolution(key); + if (rsp.code !== 0) { + messageApi.error(t('screen.controlRegion.saveFailed')); + return; + } + setSelectedResolution(key); + setIsPopoverOpen(true); + } + + async function saveResolutions(next: ResolutionPreset[], nextSelected = selectedResolution) { + const rsp = await setOriginalResolutionConfig(next, nextSelected); + if (rsp.code !== 0) { + messageApi.error(t('screen.controlRegion.saveFailed')); + return false; + } + setResolutions(next); + return true; + } + + async function addResolution() { + if (!newWidth || !newHeight) { + messageApi.error(t('screen.controlRegion.invalidResolution')); + return; + } + const resolution = { width: newWidth, height: newHeight }; + if (resolutions.some((item) => resolutionKey(item) === resolutionKey(resolution))) { + messageApi.warning(t('screen.controlRegion.duplicateResolution')); + return; + } + if (await saveResolutions([...resolutions, resolution])) { + setNewWidth(null); + setNewHeight(null); + setIsAddOpen(false); + } + } + + async function deleteResolution(index: number) { + if (index < 0 || index >= resolutions.length) return; + const deleted = resolutionKey(resolutions[index]); + const nextSelected = selectedResolution === deleted ? '' : selectedResolution; + if ( + await saveResolutions( + resolutions.filter((_, itemIndex) => itemIndex !== index), + nextSelected + ) + ) { + if (!nextSelected) { + setSelectedResolution(''); + setInputRegion(manualInputRegion); + } + } + } + + async function updateMode(nextMode: ControlRegionMode) { + const rsp = await setControlRegionMode(nextMode); + if (rsp.code !== 0) { + messageApi.error(t('screen.controlRegion.saveFailed')); + return; + } + setMode(nextMode); + if (nextMode === 'manual') { + const config = await loadConfig(); + const manualRegion = isValidInputRegion(config.data as InputRegion) + ? (config.data as InputRegion) + : null; + setManualInputRegion(manualRegion); + setInputRegion( + (config.data as ControlRegionConfig)?.selectedResolution ? null : manualRegion + ); + } else { + setInputRegion(null); + } + } + + function selectArea() { + handleOpenChange(false); + requestMenuClose((signal) => signal + 1); + setSelecting(true); + } + + function handleOpenChange(open: boolean) { + setIsPopoverOpen(open); + setKeyboardLock({ source: 'control-region-popover', locked: open }); + if (open) loadConfig(); + } + + const content = ( +
+ + + block + value={mode} + options={[ + { label: t('screen.controlRegion.off'), value: 'off' }, + { label: t('screen.controlRegion.auto'), value: 'auto' }, + { label: t('screen.controlRegion.manual'), value: 'manual' } + ]} + onChange={updateMode} + /> + + {t('screen.controlRegion.description')} + + {mode === 'auto' && ( + + {t('screen.controlRegion.autoWarning')} + + )} + {mode === 'manual' && ( + <> + + {t('screen.controlRegion.originalResolution')} + +