mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat(web): add configurable mouse input regions
Add device-level input-region APIs for reading, validating, and atomically persisting absolute mouse calibration settings, including automatic, manual, and disabled modes. Introduce a shared screen viewport that keeps the rendered media geometry and configured input region aligned across MJPEG, direct H.264, and WebRTC streams. Add automatic black-border detection and a manual selection overlay with dragging, resizing, magnified preview, keyboard locking, and cancellation support. Provide original-resolution presets so manual calibration can be recalculated for common aspect ratios, expose the controls from the desktop mouse menu, close menus before entering selection mode, and preserve active file transfers when the download menu closes. Add localized control-region labels and messages for all supported locales, and update direct-frame rendering so media dimensions are reported after the canvas has been drawn.
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
283
server/service/vm/input_region.go
Normal file
283
server/service/vm/input_region.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
'特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。',
|
||||
|
||||
@@ -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:
|
||||
'일부 해상도에서는 화면이 왜곡되거나 마우스 동작이 비정상적으로 나타날 수 있습니다. 원격 컴퓨터의 해상도를 변경하거나 자동 설정 대신 수동 설정을 사용해 보세요.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
'При некоторых разрешениях экрана могут возникать артефакты изображения или смещение курсора. Пожалуйста, настройте разрешение удаленного компьютера или отключите автоматический режим для передачи видеопотока.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
'อาการภาพฉีกขาดหรือเมาส์ไม่ตรงตำแหน่งอาจเกิดขึ้นที่ความละเอียดบางระดับ แนะนำให้ปรับความละเอียดของคอมพิวเตอร์ต้นทางหรือปิดโหมดอัตโนมัติ',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
'Може виникнути розрив зображення або зміщення миші при певних роздільних здатностях. Розгляньте можливість налаштування роздільної здатності віддаленого хоста або вимкнення автоматичного режиму для передачі відеопотоку.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
'在某些分辨率下可能存在花屏或鼠标偏移的情况,请调整远程主机分辨率或者不使用自动模式。',
|
||||
|
||||
@@ -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:
|
||||
'在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。',
|
||||
|
||||
@@ -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<number>(1.0);
|
||||
|
||||
// browser screen resolution
|
||||
export const resolutionAtom = atom<Resolution | null>(null);
|
||||
|
||||
// currently effective absolute mouse input region
|
||||
export const inputRegionAtom = atom<InputRegion | null>(null);
|
||||
export const manualInputRegionAtom = atom<InputRegion | null>(null);
|
||||
export const selectedOriginalResolutionAtom = atom<string>('');
|
||||
|
||||
// device-level control region mode; disabled by default
|
||||
export const controlRegionModeAtom = atom<ControlRegionMode>('off');
|
||||
|
||||
// show the live input-region selection overlay
|
||||
export const inputRegionSelectingAtom = atom(false);
|
||||
|
||||
@@ -6,6 +6,9 @@ export const menuDisabledItemsAtom = atom<string[]>([]);
|
||||
// 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('');
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout> | 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 = () => {
|
||||
</Splitter>
|
||||
</div>
|
||||
<ActionOverlay />
|
||||
<AutoRegion />
|
||||
<ManualRegion />
|
||||
<InputRegionOverlay />
|
||||
<Mouse />
|
||||
<Keyboard />
|
||||
</div>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => {
|
||||
<Speed />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
|
||||
<OriginalResolution />
|
||||
<HidMode />
|
||||
<ResetHid />
|
||||
</div>
|
||||
|
||||
293
web/src/pages/desktop/menu/screen/original-resolution.tsx
Normal file
293
web/src/pages/desktop/menu/screen/original-resolution.tsx
Normal file
@@ -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<ResolutionPreset[]>([]);
|
||||
const [newWidth, setNewWidth] = useState<number | null>(null);
|
||||
const [newHeight, setNewHeight] = useState<number | null>(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 = (
|
||||
<div className="w-[250px]">
|
||||
<Space direction="vertical" size="small" className="mt-2 w-full">
|
||||
<Segmented<ControlRegionMode>
|
||||
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}
|
||||
/>
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{t('screen.controlRegion.description')}
|
||||
</Typography.Text>
|
||||
{mode === 'auto' && (
|
||||
<Typography.Text type="warning" className="text-xs">
|
||||
{t('screen.controlRegion.autoWarning')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{mode === 'manual' && (
|
||||
<>
|
||||
<Button block type="primary" icon={<ScanSearchIcon size={14} />} onClick={selectArea}>
|
||||
{t('screen.controlRegion.select')}
|
||||
</Button>
|
||||
<Typography.Text>{t('screen.controlRegion.originalResolution')}</Typography.Text>
|
||||
<Space.Compact block>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedResolution}
|
||||
placeholder={t('screen.controlRegion.selectResolution')}
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
options={resolutions
|
||||
.map((resolution) => ({
|
||||
label: resolutionKey(resolution),
|
||||
value: resolutionKey(resolution)
|
||||
}))
|
||||
.concat([{ label: t('screen.controlRegion.useSelectedArea'), value: '' }])}
|
||||
optionRender={(option) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.label}</span>
|
||||
{option.value !== '' && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
deleteResolution(
|
||||
resolutions.findIndex(
|
||||
(resolution) => resolutionKey(resolution) === option.value
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onChange={applyResolution}
|
||||
/>
|
||||
<Tooltip title={t('screen.controlRegion.addResolution')}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setIsAddOpen(true)} />
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Popover
|
||||
content={content}
|
||||
placement="rightTop"
|
||||
arrow={false}
|
||||
align={{ offset: [14, 0] }}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<ScanSearchIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.controlRegion.title')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
<Modal
|
||||
title={t('screen.controlRegion.addResolution')}
|
||||
open={isAddOpen}
|
||||
okText={t('screen.controlRegion.add')}
|
||||
cancelText={t('screen.controlRegion.cancel')}
|
||||
onOk={addResolution}
|
||||
onCancel={() => setIsAddOpen(false)}
|
||||
>
|
||||
<Space.Compact block>
|
||||
<InputNumber<number>
|
||||
className="w-full"
|
||||
min={1}
|
||||
precision={0}
|
||||
value={newWidth}
|
||||
placeholder={t('screen.controlRegion.width')}
|
||||
onChange={setNewWidth}
|
||||
/>
|
||||
<InputNumber<number>
|
||||
className="w-full"
|
||||
min={1}
|
||||
precision={0}
|
||||
value={newHeight}
|
||||
placeholder={t('screen.controlRegion.height')}
|
||||
onChange={setNewHeight}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,29 +4,18 @@ import { useAtomValue } from 'jotai';
|
||||
import { MouseReportAbsolute } from '@/lib/mouse.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
import { inputRegionAtom, resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import {
|
||||
FrameContent,
|
||||
fullFrameContent,
|
||||
getConfiguredFrameContent,
|
||||
getMediaSize,
|
||||
getRenderedMediaRect,
|
||||
MediaSize
|
||||
} from '../screen/geometry.ts';
|
||||
import { MouseAbsoluteEvent } from './types.ts';
|
||||
|
||||
type MediaSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type FrameContent = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: mediaSize.width,
|
||||
height: mediaSize.height
|
||||
});
|
||||
|
||||
enum MouseButton {
|
||||
Left = 0,
|
||||
Middle = 1,
|
||||
@@ -37,6 +26,7 @@ enum MouseButton {
|
||||
|
||||
export const Absolute = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const inputRegion = useAtomValue(inputRegionAtom);
|
||||
const scrollDirection = useAtomValue(scrollDirectionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
|
||||
@@ -71,7 +61,6 @@ export const Absolute = () => {
|
||||
const target = screen;
|
||||
const mouse = mouseRef.current;
|
||||
const pressedMouseButtons = new Set<number>();
|
||||
let frameContentCache: { key: string; checkedAt: number; content: FrameContent } | null = null;
|
||||
let pendingMove: { x: number; y: number } | null = null;
|
||||
let moveFrame: number | null = null;
|
||||
|
||||
@@ -87,9 +76,6 @@ export const Absolute = () => {
|
||||
target.addEventListener('touchmove', handleTouchMove, touchOptions);
|
||||
target.addEventListener('touchend', handleTouchEnd, touchOptions);
|
||||
target.addEventListener('touchcancel', handleTouchCancel, touchOptions);
|
||||
target.addEventListener('load', invalidateFrameContent);
|
||||
target.addEventListener('loadedmetadata', invalidateFrameContent);
|
||||
target.addEventListener('canplay', invalidateFrameContent);
|
||||
|
||||
// Mouse event handler
|
||||
function handleMouseEvent(event: MouseAbsoluteEvent) {
|
||||
@@ -415,42 +401,46 @@ export const Absolute = () => {
|
||||
}
|
||||
|
||||
function getCorrectedCoords(clientX: number, clientY: number): { x: number; y: number } | null {
|
||||
const viewport = target.parentElement;
|
||||
if (viewport?.id === 'screen-viewport' && viewport.dataset.cropped === 'true') {
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
if (
|
||||
viewportRect.width <= 0 ||
|
||||
viewportRect.height <= 0 ||
|
||||
clientX < viewportRect.left ||
|
||||
clientX > viewportRect.right ||
|
||||
clientY < viewportRect.top ||
|
||||
clientY > viewportRect.bottom
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (clientX - viewportRect.left) / viewportRect.width,
|
||||
y: (clientY - viewportRect.top) / viewportRect.height
|
||||
};
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaSize =
|
||||
resolution && resolution.width > 0 && resolution.height > 0
|
||||
? resolution
|
||||
: getMediaSize(target);
|
||||
getMediaSize(target) ||
|
||||
(resolution && resolution.width > 0 && resolution.height > 0 ? resolution : null);
|
||||
if (!mediaSize) {
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const mediaRatio = mediaSize.width / mediaSize.height;
|
||||
const elementRatio = rect.width / rect.height;
|
||||
|
||||
let renderedWidth = rect.width;
|
||||
let renderedHeight = rect.height;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (mediaRatio > elementRatio) {
|
||||
renderedHeight = rect.width / mediaRatio;
|
||||
offsetY = (rect.height - renderedHeight) / 2;
|
||||
} else {
|
||||
renderedWidth = rect.height * mediaRatio;
|
||||
offsetX = (rect.width - renderedWidth) / 2;
|
||||
}
|
||||
|
||||
const frameContent = getFrameContent(mediaSize);
|
||||
const frameScaleX = renderedWidth / mediaSize.width;
|
||||
const frameScaleY = renderedHeight / mediaSize.height;
|
||||
const contentLeft = rect.left + offsetX + frameContent.left * frameScaleX;
|
||||
const contentTop = rect.top + offsetY + frameContent.top * frameScaleY;
|
||||
const renderedMediaRect = getRenderedMediaRect(rect, mediaSize);
|
||||
const frameContent = getEffectiveFrameContent(mediaSize);
|
||||
const frameScaleX = renderedMediaRect.width / mediaSize.width;
|
||||
const frameScaleY = renderedMediaRect.height / mediaSize.height;
|
||||
const contentLeft = renderedMediaRect.left + frameContent.left * frameScaleX;
|
||||
const contentTop = renderedMediaRect.top + frameContent.top * frameScaleY;
|
||||
const contentWidth = frameContent.width * frameScaleX;
|
||||
const contentHeight = frameContent.height * frameScaleY;
|
||||
|
||||
@@ -469,20 +459,11 @@ export const Absolute = () => {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function invalidateFrameContent() {
|
||||
frameContentCache = null;
|
||||
}
|
||||
|
||||
function getFrameContent(mediaSize: MediaSize): FrameContent {
|
||||
const key = `${mediaSize.width}x${mediaSize.height}`;
|
||||
const now = performance.now();
|
||||
if (frameContentCache?.key === key && now - frameContentCache.checkedAt < 1000) {
|
||||
return frameContentCache.content;
|
||||
}
|
||||
|
||||
const content = detectFrameContent(target, mediaSize);
|
||||
frameContentCache = { key, checkedAt: now, content };
|
||||
return content;
|
||||
function getEffectiveFrameContent(mediaSize: MediaSize): FrameContent {
|
||||
return (
|
||||
(inputRegion && getConfiguredFrameContent(inputRegion, mediaSize)) ||
|
||||
fullFrameContent(mediaSize)
|
||||
);
|
||||
}
|
||||
|
||||
function queueMouseMove(x: number, y: number) {
|
||||
@@ -545,15 +526,12 @@ export const Absolute = () => {
|
||||
target.removeEventListener('touchmove', handleTouchMove, touchOptions.capture);
|
||||
target.removeEventListener('touchend', handleTouchEnd, touchOptions.capture);
|
||||
target.removeEventListener('touchcancel', handleTouchCancel, touchOptions.capture);
|
||||
target.removeEventListener('load', invalidateFrameContent);
|
||||
target.removeEventListener('loadedmetadata', invalidateFrameContent);
|
||||
target.removeEventListener('canplay', invalidateFrameContent);
|
||||
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [resolution, scrollDirection, scrollInterval]);
|
||||
}, [inputRegion, resolution, scrollDirection, scrollInterval]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: Event) {
|
||||
@@ -563,118 +541,3 @@ export const Absolute = () => {
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
function detectFrameContent(screen: Element, mediaSize: MediaSize): FrameContent {
|
||||
// Frame metadata includes letterbox pixels; infer stable side borders until the capture pipeline exposes an active rect.
|
||||
// ponytail: replace pixel inference with active-content metadata when available.
|
||||
const sampleScale = Math.min(1, 640 / mediaSize.width, 360 / mediaSize.height);
|
||||
const sampleWidth = Math.max(1, Math.round(mediaSize.width * sampleScale));
|
||||
const sampleHeight = Math.max(1, Math.round(mediaSize.height * sampleScale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = sampleWidth;
|
||||
canvas.height = sampleHeight;
|
||||
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context || !drawMediaFrame(context, screen, sampleWidth, sampleHeight)) {
|
||||
return fullFrameContent(mediaSize);
|
||||
}
|
||||
|
||||
try {
|
||||
const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
|
||||
const left = findBorderInset(pixels, sampleWidth, sampleHeight, true);
|
||||
const right = findBorderInset(pixels, sampleWidth, sampleHeight, false);
|
||||
const minHorizontalBorder = Math.max(4, Math.round(sampleWidth * 0.02));
|
||||
const horizontalBorder =
|
||||
left >= minHorizontalBorder &&
|
||||
right >= minHorizontalBorder &&
|
||||
left + right < sampleWidth * 0.4 &&
|
||||
Math.abs(left - right) <= Math.max(4, Math.round(sampleWidth * 0.05));
|
||||
const frameLeft = horizontalBorder ? (left / sampleWidth) * mediaSize.width : 0;
|
||||
const frameRight = horizontalBorder ? (right / sampleWidth) * mediaSize.width : 0;
|
||||
|
||||
return {
|
||||
left: frameLeft,
|
||||
top: 0,
|
||||
width: mediaSize.width - frameLeft - frameRight,
|
||||
height: mediaSize.height
|
||||
};
|
||||
} catch {
|
||||
return fullFrameContent(mediaSize);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMediaFrame(
|
||||
context: CanvasRenderingContext2D,
|
||||
screen: Element,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
if (
|
||||
screen instanceof HTMLVideoElement ||
|
||||
screen instanceof HTMLImageElement ||
|
||||
screen instanceof HTMLCanvasElement
|
||||
) {
|
||||
try {
|
||||
context.drawImage(screen, 0, 0, width, height);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function findBorderInset(
|
||||
pixels: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
fromStart: boolean
|
||||
) {
|
||||
const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map((ratio) =>
|
||||
Math.round((height - 1) * ratio)
|
||||
);
|
||||
const limit = Math.floor(width * 0.45);
|
||||
let inset = 0;
|
||||
|
||||
for (let offset = 0; offset < limit; offset++) {
|
||||
const position = fromStart ? offset : width - offset - 1;
|
||||
const samples = lines.map((line) => getPixel(pixels, width, position, line));
|
||||
if (!samples.every(isBlackPixel)) {
|
||||
break;
|
||||
}
|
||||
|
||||
inset = offset + 1;
|
||||
}
|
||||
|
||||
return inset;
|
||||
}
|
||||
|
||||
function getPixel(pixels: Uint8ClampedArray, width: number, x: number, y: number) {
|
||||
const offset = (y * width + x) * 4;
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||
}
|
||||
|
||||
function isBlackPixel(pixel: number[]) {
|
||||
return pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 0;
|
||||
}
|
||||
|
||||
function getMediaSize(screen: Element): MediaSize | null {
|
||||
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
|
||||
return { width: screen.videoWidth, height: screen.videoHeight };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLImageElement && screen.naturalWidth > 0 && screen.naturalHeight > 0) {
|
||||
return { width: screen.naturalWidth, height: screen.naturalHeight };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLCanvasElement) {
|
||||
const width = Number(screen.dataset.mediaWidth);
|
||||
const height = Number(screen.dataset.mediaHeight);
|
||||
if (width > 0 && height > 0) {
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
75
web/src/pages/desktop/screen/auto-region.tsx
Normal file
75
web/src/pages/desktop/screen/auto-region.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
import { detectFrameContent, getMediaSize, isMediaReady } from './geometry.ts';
|
||||
|
||||
export const AutoRegion = () => {
|
||||
const mode = useAtomValue(controlRegionModeAtom);
|
||||
const selecting = useAtomValue(inputRegionSelectingAtom);
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto' || selecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen) return;
|
||||
const target = screen;
|
||||
let stopped = false;
|
||||
let candidate = '';
|
||||
let confirmations = 0;
|
||||
|
||||
const detect = () => {
|
||||
if (stopped || !isMediaReady(target)) return;
|
||||
const mediaSize = getMediaSize(target);
|
||||
if (!mediaSize) return;
|
||||
const content = detectFrameContent(target, mediaSize);
|
||||
const key = [
|
||||
mediaSize.width,
|
||||
mediaSize.height,
|
||||
content.left,
|
||||
content.top,
|
||||
content.width,
|
||||
content.height
|
||||
]
|
||||
.map(Math.round)
|
||||
.join(':');
|
||||
if (candidate === key) {
|
||||
confirmations += 1;
|
||||
} else {
|
||||
candidate = key;
|
||||
confirmations = 1;
|
||||
}
|
||||
if (confirmations === 3) {
|
||||
setInputRegion({
|
||||
frameWidth: mediaSize.width,
|
||||
frameHeight: mediaSize.height,
|
||||
left: Math.round(content.left),
|
||||
top: Math.round(content.top),
|
||||
width: Math.round(content.width),
|
||||
height: Math.round(content.height)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
detect();
|
||||
const timer = window.setInterval(detect, 1000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mode, selecting, setInputRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto') setInputRegion(null);
|
||||
}, [mode, setInputRegion]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -313,6 +313,8 @@ function renderFrame(frame: VideoFrame) {
|
||||
canvas.width = frame.displayWidth;
|
||||
canvas.height = frame.displayHeight;
|
||||
}
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (reportedFrameWidth !== frame.displayWidth || reportedFrameHeight !== frame.displayHeight) {
|
||||
reportedFrameWidth = frame.displayWidth;
|
||||
reportedFrameHeight = frame.displayHeight;
|
||||
@@ -322,8 +324,6 @@ function renderFrame(frame: VideoFrame) {
|
||||
height: reportedFrameHeight
|
||||
});
|
||||
}
|
||||
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||
} finally {
|
||||
frame.close();
|
||||
}
|
||||
|
||||
278
web/src/pages/desktop/screen/geometry.ts
Normal file
278
web/src/pages/desktop/screen/geometry.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { InputRegion } from '@/types';
|
||||
|
||||
export type MediaSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type FrameContent = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type RenderedMediaRect = FrameContent;
|
||||
|
||||
const aspectRatioTolerance = 0.01;
|
||||
|
||||
export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: mediaSize.width,
|
||||
height: mediaSize.height
|
||||
});
|
||||
|
||||
export function getMediaSize(screen: Element): MediaSize | null {
|
||||
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
|
||||
return { width: screen.videoWidth, height: screen.videoHeight };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLImageElement && screen.naturalWidth > 0 && screen.naturalHeight > 0) {
|
||||
return { width: screen.naturalWidth, height: screen.naturalHeight };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLCanvasElement) {
|
||||
const width = Number(screen.dataset.mediaWidth);
|
||||
const height = Number(screen.dataset.mediaHeight);
|
||||
if (width > 0 && height > 0) {
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isMediaReady(screen: Element) {
|
||||
if (screen instanceof HTMLVideoElement) {
|
||||
return screen.readyState >= 2 && screen.videoWidth > 0 && screen.videoHeight > 0;
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLImageElement) {
|
||||
return screen.complete && screen.naturalWidth > 0 && screen.naturalHeight > 0;
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLCanvasElement) {
|
||||
return Number(screen.dataset.mediaWidth) > 0 && Number(screen.dataset.mediaHeight) > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function detectFrameContent(screen: Element, mediaSize: MediaSize): FrameContent {
|
||||
const sampleScale = Math.min(1, 640 / mediaSize.width, 360 / mediaSize.height);
|
||||
const width = Math.max(1, Math.round(mediaSize.width * sampleScale));
|
||||
const height = Math.max(1, Math.round(mediaSize.height * sampleScale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context || !drawMediaFrame(context, screen, width, height)) {
|
||||
return fullFrameContent(mediaSize);
|
||||
}
|
||||
|
||||
try {
|
||||
const pixels = context.getImageData(0, 0, width, height).data;
|
||||
const left = findBorderInset(pixels, width, height, true, 'horizontal');
|
||||
const right = findBorderInset(pixels, width, height, false, 'horizontal');
|
||||
const top = findBorderInset(pixels, width, height, true, 'vertical');
|
||||
const bottom = findBorderInset(pixels, width, height, false, 'vertical');
|
||||
const horizontalBorder =
|
||||
left >= Math.max(4, Math.round(width * 0.02)) &&
|
||||
right >= Math.max(4, Math.round(width * 0.02)) &&
|
||||
left + right < width * 0.4 &&
|
||||
Math.abs(left - right) <= Math.max(4, Math.round(width * 0.05));
|
||||
const verticalBorder =
|
||||
top >= Math.max(4, Math.round(height * 0.02)) &&
|
||||
bottom >= Math.max(4, Math.round(height * 0.02)) &&
|
||||
top + bottom < height * 0.4 &&
|
||||
Math.abs(top - bottom) <= Math.max(4, Math.round(height * 0.05));
|
||||
const frameLeft = horizontalBorder ? (left / width) * mediaSize.width : 0;
|
||||
const frameRight = horizontalBorder ? (right / width) * mediaSize.width : 0;
|
||||
const frameTop = verticalBorder ? (top / height) * mediaSize.height : 0;
|
||||
const frameBottom = verticalBorder ? (bottom / height) * mediaSize.height : 0;
|
||||
return {
|
||||
left: frameLeft,
|
||||
top: frameTop,
|
||||
width: mediaSize.width - frameLeft - frameRight,
|
||||
height: mediaSize.height - frameTop - frameBottom
|
||||
};
|
||||
} catch {
|
||||
return fullFrameContent(mediaSize);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMediaFrame(
|
||||
context: CanvasRenderingContext2D,
|
||||
screen: Element,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
if (
|
||||
screen instanceof HTMLVideoElement ||
|
||||
screen instanceof HTMLImageElement ||
|
||||
screen instanceof HTMLCanvasElement
|
||||
) {
|
||||
try {
|
||||
context.drawImage(screen, 0, 0, width, height);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findBorderInset(
|
||||
pixels: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
fromStart: boolean,
|
||||
axis: 'horizontal' | 'vertical'
|
||||
) {
|
||||
const scanSize = axis === 'horizontal' ? width : height;
|
||||
const sampleSize = axis === 'horizontal' ? height : width;
|
||||
const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map((ratio) =>
|
||||
Math.round((sampleSize - 1) * ratio)
|
||||
);
|
||||
const limit = Math.floor(scanSize * 0.45);
|
||||
let inset = 0;
|
||||
for (let offset = 0; offset < limit; offset++) {
|
||||
const black = lines.every((line) => {
|
||||
const x = axis === 'horizontal' ? (fromStart ? offset : width - offset - 1) : line;
|
||||
const y = axis === 'horizontal' ? line : fromStart ? offset : height - offset - 1;
|
||||
const index = (y * width + x) * 4;
|
||||
return pixels[index] === 0 && pixels[index + 1] === 0 && pixels[index + 2] === 0;
|
||||
});
|
||||
if (!black) break;
|
||||
inset = offset + 1;
|
||||
}
|
||||
return inset;
|
||||
}
|
||||
|
||||
export function getRenderedMediaRect(
|
||||
elementRect: DOMRect,
|
||||
mediaSize: MediaSize
|
||||
): RenderedMediaRect {
|
||||
const mediaRatio = mediaSize.width / mediaSize.height;
|
||||
const elementRatio = elementRect.width / elementRect.height;
|
||||
|
||||
let width = elementRect.width;
|
||||
let height = elementRect.height;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (mediaRatio > elementRatio) {
|
||||
height = elementRect.width / mediaRatio;
|
||||
offsetY = (elementRect.height - height) / 2;
|
||||
} else {
|
||||
width = elementRect.height * mediaRatio;
|
||||
offsetX = (elementRect.width - width) / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
left: elementRect.left + offsetX,
|
||||
top: elementRect.top + offsetY,
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfiguredFrameContent(
|
||||
region: InputRegion,
|
||||
mediaSize: MediaSize
|
||||
): FrameContent | null {
|
||||
if (!isValidInputRegion(region)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isInputRegionCompatible(region, mediaSize)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scaleX = mediaSize.width / region.frameWidth;
|
||||
const scaleY = mediaSize.height / region.frameHeight;
|
||||
const content = {
|
||||
left: region.left * scaleX,
|
||||
top: region.top * scaleY,
|
||||
width: region.width * scaleX,
|
||||
height: region.height * scaleY
|
||||
};
|
||||
|
||||
if (
|
||||
content.left < 0 ||
|
||||
content.top < 0 ||
|
||||
content.width <= 0 ||
|
||||
content.height <= 0 ||
|
||||
content.left + content.width > mediaSize.width + 0.001 ||
|
||||
content.top + content.height > mediaSize.height + 0.001
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function isInputRegionCompatible(region: InputRegion, mediaSize: MediaSize) {
|
||||
return (
|
||||
Math.abs(region.frameWidth / region.frameHeight - mediaSize.width / mediaSize.height) <=
|
||||
aspectRatioTolerance
|
||||
);
|
||||
}
|
||||
|
||||
export function getCenteredInputRegionByAspectRatio(
|
||||
width: number,
|
||||
height: number,
|
||||
mediaSize: MediaSize
|
||||
): InputRegion | null {
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetRatio = width / height;
|
||||
const frameRatio = mediaSize.width / mediaSize.height;
|
||||
const regionWidth = Math.round(
|
||||
targetRatio < frameRatio ? mediaSize.height * targetRatio : mediaSize.width
|
||||
);
|
||||
const regionHeight = Math.round(
|
||||
targetRatio < frameRatio ? mediaSize.height : mediaSize.width / targetRatio
|
||||
);
|
||||
|
||||
return {
|
||||
frameWidth: mediaSize.width,
|
||||
frameHeight: mediaSize.height,
|
||||
left: Math.floor((mediaSize.width - regionWidth) / 2),
|
||||
top: Math.floor((mediaSize.height - regionHeight) / 2),
|
||||
width: regionWidth,
|
||||
height: regionHeight
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidInputRegion(region: InputRegion | null | undefined): region is InputRegion {
|
||||
if (!region) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const values = [
|
||||
region.frameWidth,
|
||||
region.frameHeight,
|
||||
region.left,
|
||||
region.top,
|
||||
region.width,
|
||||
region.height
|
||||
];
|
||||
if (!values.every((value) => Number.isInteger(value) && Number.isFinite(value))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
region.frameWidth > 0 &&
|
||||
region.frameHeight > 0 &&
|
||||
region.left >= 0 &&
|
||||
region.top >= 0 &&
|
||||
region.width > 0 &&
|
||||
region.height > 0 &&
|
||||
region.left + region.width <= region.frameWidth &&
|
||||
region.top + region.height <= region.frameHeight
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse';
|
||||
import { videoScaleAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import DirectWorker from './direct.worker.ts?worker';
|
||||
import { ScreenViewport } from './viewport.tsx';
|
||||
|
||||
export const H264Direct = () => {
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scale = storage.getVideoScale();
|
||||
if (scale) {
|
||||
setVideoScale(scale);
|
||||
}
|
||||
}, [setVideoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.VideoDecoder) {
|
||||
console.log('Error: WebCodecs API not supported.');
|
||||
@@ -57,19 +48,12 @@ export const H264Direct = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden">
|
||||
<ScreenViewport>
|
||||
<canvas
|
||||
id="screen"
|
||||
ref={canvasRef}
|
||||
className={clsx('block touch-none select-none', mouseStyle)}
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
></canvas>
|
||||
</div>
|
||||
</ScreenViewport>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { notification, Spin } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { videoScaleAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { ScreenViewport } from './viewport.tsx';
|
||||
|
||||
type SignalingMessage = {
|
||||
event?: string;
|
||||
@@ -30,7 +30,6 @@ const parseSignalingData = <T,>(data?: string): T | null => {
|
||||
export const H264Webrtc = () => {
|
||||
const { t } = useTranslation();
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [connectionAttempt, setConnectionAttempt] = useState(0);
|
||||
const [notificationApi, contextHolder] = notification.useNotification();
|
||||
@@ -312,29 +311,15 @@ export const H264Webrtc = () => {
|
||||
};
|
||||
}, [notificationApi]);
|
||||
|
||||
useEffect(() => {
|
||||
const scale = storage.getVideoScale();
|
||||
if (scale) {
|
||||
setVideoScale(scale);
|
||||
}
|
||||
}, [setVideoScale]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 w-full min-w-0 overflow-hidden">
|
||||
{contextHolder}
|
||||
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden">
|
||||
<ScreenViewport>
|
||||
<video
|
||||
id="screen"
|
||||
ref={videoRef}
|
||||
className={clsx('block select-none touch-none', mouseStyle)}
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
className={clsx('block touch-none select-none', mouseStyle)}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
@@ -347,7 +332,7 @@ export const H264Webrtc = () => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScreenViewport>
|
||||
|
||||
{isLoading && (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[2px] transition-all duration-300">
|
||||
|
||||
746
web/src/pages/desktop/screen/input-region-overlay.tsx
Normal file
746
web/src/pages/desktop/screen/input-region-overlay.tsx
Normal file
@@ -0,0 +1,746 @@
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent } from 'react';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Space, theme } from 'antd';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import Draggable from 'react-draggable';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setInputRegionConfig } from '@/api/vm.ts';
|
||||
import { InputRegion } from '@/types';
|
||||
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
|
||||
import {
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom,
|
||||
manualInputRegionAtom,
|
||||
selectedOriginalResolutionAtom,
|
||||
videoScaleAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
import { getMediaSize, getRenderedMediaRect, MediaSize, RenderedMediaRect } from './geometry.ts';
|
||||
|
||||
type SelectionRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
|
||||
|
||||
const resizeHandles: Array<{
|
||||
handle: ResizeHandle;
|
||||
className: string;
|
||||
cursor: string;
|
||||
}> = [
|
||||
{
|
||||
handle: 'n',
|
||||
className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2',
|
||||
cursor: 'ns-resize'
|
||||
},
|
||||
{
|
||||
handle: 'ne',
|
||||
className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2',
|
||||
cursor: 'nesw-resize'
|
||||
},
|
||||
{
|
||||
handle: 'e',
|
||||
className: 'right-0 top-1/2 translate-x-1/2 -translate-y-1/2',
|
||||
cursor: 'ew-resize'
|
||||
},
|
||||
{
|
||||
handle: 'se',
|
||||
className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2',
|
||||
cursor: 'nwse-resize'
|
||||
},
|
||||
{
|
||||
handle: 's',
|
||||
className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2',
|
||||
cursor: 'ns-resize'
|
||||
},
|
||||
{
|
||||
handle: 'sw',
|
||||
className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2',
|
||||
cursor: 'nesw-resize'
|
||||
},
|
||||
{
|
||||
handle: 'w',
|
||||
className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
cursor: 'ew-resize'
|
||||
},
|
||||
{
|
||||
handle: 'nw',
|
||||
className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2',
|
||||
cursor: 'nwse-resize'
|
||||
}
|
||||
];
|
||||
|
||||
const minimumSelectionSize = 4;
|
||||
|
||||
export const InputRegionOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [selecting, setSelecting] = useAtom(inputRegionSelectingAtom);
|
||||
const [, setInputRegionState] = useAtom(inputRegionAtom);
|
||||
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
|
||||
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
|
||||
const videoScale = useAtomValue(videoScaleAtom);
|
||||
const setVideoScale = useSetAtom(videoScaleAtom);
|
||||
const setKeyboardLock = useSetAtom(keyboardLockAtom);
|
||||
const [frameRect, setFrameRect] = useState<RenderedMediaRect | null>(null);
|
||||
const [mediaSize, setMediaSize] = useState<MediaSize | null>(null);
|
||||
const [selection, setSelection] = useState<SelectionRect | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [cursorPoint, setCursorPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const resizeRef = useRef<{
|
||||
handle: ResizeHandle;
|
||||
selection: SelectionRect;
|
||||
} | null>(null);
|
||||
const moveRef = useRef<{
|
||||
start: { x: number; y: number };
|
||||
selection: SelectionRect;
|
||||
} | null>(null);
|
||||
const frameRectRef = useRef<RenderedMediaRect | null>(null);
|
||||
const cursorPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const magnifierCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
const previousVideoScaleRef = useRef(videoScale);
|
||||
|
||||
if (!selecting) {
|
||||
previousVideoScaleRef.current = videoScale;
|
||||
}
|
||||
|
||||
const cancelSelection = useCallback(() => {
|
||||
dragStartRef.current = null;
|
||||
resizeRef.current = null;
|
||||
moveRef.current = null;
|
||||
setDragging(false);
|
||||
setSelection(null);
|
||||
setError('');
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
setSelecting(false);
|
||||
}, [setSelecting]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSelecting(false);
|
||||
}, [setSelecting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyboardLock({ source: 'input-region-selector', locked: true });
|
||||
return () => setKeyboardLock({ source: 'input-region-selector', locked: false });
|
||||
}, [selecting, setKeyboardLock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousScale = previousVideoScaleRef.current;
|
||||
setVideoScale(0.75);
|
||||
return () => setVideoScale(previousScale);
|
||||
}, [selecting, setVideoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen) {
|
||||
setFrameRect(null);
|
||||
setMediaSize(null);
|
||||
frameRectRef.current = null;
|
||||
return;
|
||||
}
|
||||
const target = screen;
|
||||
|
||||
function updateFrame() {
|
||||
const nextMediaSize = getMediaSize(target);
|
||||
const bounds = target.getBoundingClientRect();
|
||||
if (!nextMediaSize || bounds.width <= 0 || bounds.height <= 0) {
|
||||
setFrameRect(null);
|
||||
setMediaSize(null);
|
||||
frameRectRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFrameRect = getRenderedMediaRect(bounds, nextMediaSize);
|
||||
const previousFrameRect = frameRectRef.current;
|
||||
if (
|
||||
previousFrameRect &&
|
||||
(previousFrameRect.left !== nextFrameRect.left ||
|
||||
previousFrameRect.top !== nextFrameRect.top ||
|
||||
previousFrameRect.width !== nextFrameRect.width ||
|
||||
previousFrameRect.height !== nextFrameRect.height)
|
||||
) {
|
||||
dragStartRef.current = null;
|
||||
resizeRef.current = null;
|
||||
moveRef.current = null;
|
||||
setDragging(false);
|
||||
setSelection(null);
|
||||
}
|
||||
|
||||
setMediaSize(nextMediaSize);
|
||||
setFrameRect(nextFrameRect);
|
||||
frameRectRef.current = nextFrameRect;
|
||||
}
|
||||
|
||||
updateFrame();
|
||||
const resizeObserver = new ResizeObserver(updateFrame);
|
||||
resizeObserver.observe(target);
|
||||
const mutationObserver = new MutationObserver(updateFrame);
|
||||
mutationObserver.observe(target, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-media-width', 'data-media-height']
|
||||
});
|
||||
window.addEventListener('resize', updateFrame);
|
||||
window.addEventListener('scroll', updateFrame, true);
|
||||
target.addEventListener('load', updateFrame);
|
||||
target.addEventListener('loadedmetadata', updateFrame);
|
||||
target.addEventListener('canplay', updateFrame);
|
||||
target.addEventListener('resize', updateFrame);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
window.removeEventListener('resize', updateFrame);
|
||||
window.removeEventListener('scroll', updateFrame, true);
|
||||
target.removeEventListener('load', updateFrame);
|
||||
target.removeEventListener('loadedmetadata', updateFrame);
|
||||
target.removeEventListener('canplay', updateFrame);
|
||||
target.removeEventListener('resize', updateFrame);
|
||||
};
|
||||
}, [selecting, videoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
cancelSelection();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [cancelSelection, selecting]);
|
||||
|
||||
const hasCursorPoint = cursorPoint !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting || !frameRect || !mediaSize || !hasCursorPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = magnifierCanvasRef.current;
|
||||
const screen = document.getElementById('screen');
|
||||
if (!canvas || !screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
screen instanceof HTMLVideoElement ||
|
||||
screen instanceof HTMLImageElement ||
|
||||
screen instanceof HTMLCanvasElement
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const target = screen;
|
||||
const drawingContext = context;
|
||||
const currentFrameRect = frameRect;
|
||||
const currentMediaSize = mediaSize;
|
||||
|
||||
const magnifierWidth = 180;
|
||||
const magnifierHeight = 130;
|
||||
const zoom = 3;
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
canvas.width = magnifierWidth * pixelRatio;
|
||||
canvas.height = magnifierHeight * pixelRatio;
|
||||
|
||||
let animationFrame = 0;
|
||||
function renderMagnifier() {
|
||||
const point = cursorPointRef.current;
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaX =
|
||||
((point.x - currentFrameRect.left) / currentFrameRect.width) * currentMediaSize.width;
|
||||
const mediaY =
|
||||
((point.y - currentFrameRect.top) / currentFrameRect.height) * currentMediaSize.height;
|
||||
const sourceWidth = Math.min(currentMediaSize.width, magnifierWidth / zoom);
|
||||
const sourceHeight = Math.min(currentMediaSize.height, magnifierHeight / zoom);
|
||||
const unclippedLeft = mediaX - sourceWidth / 2;
|
||||
const unclippedTop = mediaY - sourceHeight / 2;
|
||||
const sourceLeft = Math.max(0, unclippedLeft);
|
||||
const sourceTop = Math.max(0, unclippedTop);
|
||||
const sourceRight = Math.min(currentMediaSize.width, unclippedLeft + sourceWidth);
|
||||
const sourceBottom = Math.min(currentMediaSize.height, unclippedTop + sourceHeight);
|
||||
const clippedWidth = sourceRight - sourceLeft;
|
||||
const clippedHeight = sourceBottom - sourceTop;
|
||||
const destinationLeft = (sourceLeft - unclippedLeft) * zoom;
|
||||
const destinationTop = (sourceTop - unclippedTop) * zoom;
|
||||
|
||||
drawingContext.save();
|
||||
drawingContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
drawingContext.clearRect(0, 0, magnifierWidth, magnifierHeight);
|
||||
drawingContext.fillStyle = '#111827';
|
||||
drawingContext.fillRect(0, 0, magnifierWidth, magnifierHeight);
|
||||
drawingContext.imageSmoothingEnabled = true;
|
||||
|
||||
try {
|
||||
drawingContext.drawImage(
|
||||
target,
|
||||
sourceLeft,
|
||||
sourceTop,
|
||||
clippedWidth,
|
||||
clippedHeight,
|
||||
destinationLeft,
|
||||
destinationTop,
|
||||
clippedWidth * zoom,
|
||||
clippedHeight * zoom
|
||||
);
|
||||
} catch {
|
||||
drawingContext.fillStyle = '#f9fafb';
|
||||
drawingContext.font = '12px sans-serif';
|
||||
drawingContext.textAlign = 'center';
|
||||
drawingContext.fillText(
|
||||
t('screen.controlRegion.previewUnavailable'),
|
||||
magnifierWidth / 2,
|
||||
magnifierHeight / 2
|
||||
);
|
||||
}
|
||||
|
||||
drawingContext.restore();
|
||||
animationFrame = requestAnimationFrame(renderMagnifier);
|
||||
}
|
||||
|
||||
renderMagnifier();
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [frameRect, hasCursorPoint, mediaSize, selecting, t]);
|
||||
|
||||
if (!selecting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function clampPoint(x: number, y: number) {
|
||||
if (!frameRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.max(frameRect.left, Math.min(frameRect.left + frameRect.width, x)),
|
||||
y: Math.max(frameRect.top, Math.min(frameRect.top + frameRect.height, y))
|
||||
};
|
||||
}
|
||||
|
||||
function updateSelection(x: number, y: number) {
|
||||
const start = dragStartRef.current;
|
||||
const point = clampPoint(x, y);
|
||||
if (!start || !point) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({
|
||||
left: Math.min(start.x, point.x),
|
||||
top: Math.min(start.y, point.y),
|
||||
width: Math.abs(point.x - start.x),
|
||||
height: Math.abs(point.y - start.y)
|
||||
});
|
||||
}
|
||||
|
||||
function updateResizedSelection(x: number, y: number) {
|
||||
if (!frameRect || !resizeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handle, selection: initial } = resizeRef.current;
|
||||
let left = initial.left;
|
||||
let top = initial.top;
|
||||
let right = initial.left + initial.width;
|
||||
let bottom = initial.top + initial.height;
|
||||
|
||||
if (handle.includes('w')) {
|
||||
left = Math.max(frameRect.left, Math.min(right - minimumSelectionSize, x));
|
||||
}
|
||||
if (handle.includes('e')) {
|
||||
right = Math.min(frameRect.left + frameRect.width, Math.max(left + minimumSelectionSize, x));
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
top = Math.max(frameRect.top, Math.min(bottom - minimumSelectionSize, y));
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
bottom = Math.min(frameRect.top + frameRect.height, Math.max(top + minimumSelectionSize, y));
|
||||
}
|
||||
|
||||
const nextSelection = { left, top, width: right - left, height: bottom - top };
|
||||
setSelection(nextSelection);
|
||||
updateCursorPoint(
|
||||
handle.includes('w') ? left : handle.includes('e') ? right : left + nextSelection.width / 2,
|
||||
handle.includes('n') ? top : handle.includes('s') ? bottom : top + nextSelection.height / 2,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function updateMovedSelection(x: number, y: number) {
|
||||
if (!frameRect || !moveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, selection: initial } = moveRef.current;
|
||||
const left = Math.max(
|
||||
frameRect.left,
|
||||
Math.min(frameRect.left + frameRect.width - initial.width, initial.left + x - start.x)
|
||||
);
|
||||
const top = Math.max(
|
||||
frameRect.top,
|
||||
Math.min(frameRect.top + frameRect.height - initial.height, initial.top + y - start.y)
|
||||
);
|
||||
|
||||
setSelection({ ...initial, left, top });
|
||||
}
|
||||
|
||||
function updateCursorPoint(x: number, y: number, force = false) {
|
||||
if (
|
||||
!frameRect ||
|
||||
x < frameRect.left ||
|
||||
x > frameRect.left + frameRect.width ||
|
||||
y < frameRect.top ||
|
||||
y > frameRect.top + frameRect.height
|
||||
) {
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!force &&
|
||||
selection &&
|
||||
x >= selection.left &&
|
||||
x <= selection.left + selection.width &&
|
||||
y >= selection.top &&
|
||||
y <= selection.top + selection.height
|
||||
) {
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const point = { x, y };
|
||||
cursorPointRef.current = point;
|
||||
setCursorPoint(point);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||
if (
|
||||
!frameRect ||
|
||||
event.clientX < frameRect.left ||
|
||||
event.clientX > frameRect.left + frameRect.width ||
|
||||
event.clientY < frameRect.top ||
|
||||
event.clientY > frameRect.top + frameRect.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = clampPoint(event.clientX, event.clientY);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
updateCursorPoint(event.clientX, event.clientY, true);
|
||||
resizeRef.current = null;
|
||||
moveRef.current = null;
|
||||
dragStartRef.current = point;
|
||||
setSelection({ left: point.x, top: point.y, width: 0, height: 0 });
|
||||
setDragging(true);
|
||||
setError('');
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
|
||||
if (moveRef.current) {
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
} else if (resizeRef.current) {
|
||||
updateResizedSelection(event.clientX, event.clientY);
|
||||
} else {
|
||||
updateCursorPoint(event.clientX, event.clientY, dragging);
|
||||
if (dragging) {
|
||||
updateSelection(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
if (moveRef.current) {
|
||||
updateMovedSelection(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
|
||||
if (moveRef.current) {
|
||||
updateMovedSelection(event.clientX, event.clientY);
|
||||
moveRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resizeRef.current) {
|
||||
updateResizedSelection(event.clientX, event.clientY);
|
||||
resizeRef.current = null;
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
return;
|
||||
}
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelection(event.clientX, event.clientY);
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
dragStartRef.current = null;
|
||||
setDragging(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeStart(
|
||||
event: PointerEvent<HTMLDivElement>,
|
||||
handle: ResizeHandle,
|
||||
currentSelection: SelectionRect
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStartRef.current = null;
|
||||
setDragging(false);
|
||||
resizeRef.current = { handle, selection: currentSelection };
|
||||
moveRef.current = null;
|
||||
updateResizedSelection(event.clientX, event.clientY);
|
||||
setError('');
|
||||
}
|
||||
|
||||
function handleMoveStart(event: PointerEvent<HTMLDivElement>, currentSelection: SelectionRect) {
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStartRef.current = null;
|
||||
resizeRef.current = null;
|
||||
setDragging(false);
|
||||
moveRef.current = {
|
||||
start: { x: event.clientX, y: event.clientY },
|
||||
selection: currentSelection
|
||||
};
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!frameRect || !mediaSize || !selection) {
|
||||
return;
|
||||
}
|
||||
if (selection.width < minimumSelectionSize || selection.height < minimumSelectionSize) {
|
||||
setError(t('screen.controlRegion.tooSmall'));
|
||||
return;
|
||||
}
|
||||
|
||||
const left = Math.round(
|
||||
((selection.left - frameRect.left) / frameRect.width) * mediaSize.width
|
||||
);
|
||||
const top = Math.round(((selection.top - frameRect.top) / frameRect.height) * mediaSize.height);
|
||||
const right = Math.round(
|
||||
((selection.left + selection.width - frameRect.left) / frameRect.width) * mediaSize.width
|
||||
);
|
||||
const bottom = Math.round(
|
||||
((selection.top + selection.height - frameRect.top) / frameRect.height) * mediaSize.height
|
||||
);
|
||||
const region: InputRegion = {
|
||||
frameWidth: mediaSize.width,
|
||||
frameHeight: mediaSize.height,
|
||||
left,
|
||||
top,
|
||||
width: Math.max(1, right - left),
|
||||
height: Math.max(1, bottom - top)
|
||||
};
|
||||
|
||||
const rsp = await setInputRegionConfig(region, '');
|
||||
if (rsp.code !== 0) {
|
||||
setError(t('screen.controlRegion.saveFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setManualInputRegion(region);
|
||||
setSelectedOriginalResolution('');
|
||||
setInputRegionState(region);
|
||||
cursorPointRef.current = null;
|
||||
setCursorPoint(null);
|
||||
setSelecting(false);
|
||||
setSelection(null);
|
||||
}
|
||||
|
||||
const selectionStyle = selection
|
||||
? {
|
||||
left: selection.left,
|
||||
top: selection.top,
|
||||
width: selection.width,
|
||||
height: selection.height
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const magnifierStyle = cursorPoint
|
||||
? {
|
||||
left:
|
||||
cursorPoint.x + 24 + 180 > window.innerWidth ? cursorPoint.x - 204 : cursorPoint.x + 24,
|
||||
top:
|
||||
cursorPoint.y + 24 + 130 > window.innerHeight ? cursorPoint.y - 154 : cursorPoint.y + 24
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1100] touch-none select-none"
|
||||
style={{ background: token.colorBgMask }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={(event) => {
|
||||
updateCursorPoint(-1, -1);
|
||||
handlePointerUp(event);
|
||||
}}
|
||||
onPointerLeave={() => updateCursorPoint(-1, -1)}
|
||||
>
|
||||
{frameRect && (
|
||||
<div
|
||||
className="pointer-events-none fixed border border-dashed"
|
||||
style={{
|
||||
left: frameRect.left,
|
||||
top: frameRect.top,
|
||||
width: frameRect.width,
|
||||
height: frameRect.height,
|
||||
borderColor: token.colorTextSecondary
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selection && (
|
||||
<div
|
||||
className="pointer-events-auto fixed border"
|
||||
style={{
|
||||
...selectionStyle,
|
||||
cursor: 'move',
|
||||
borderColor: token.colorPrimary,
|
||||
background: `color-mix(in srgb, ${token.colorPrimary} 18%, transparent)`,
|
||||
boxShadow: token.boxShadowSecondary
|
||||
}}
|
||||
onPointerDown={(event) => handleMoveStart(event, selection)}
|
||||
>
|
||||
{resizeHandles.map(({ handle, className, cursor }) => (
|
||||
<div
|
||||
key={handle}
|
||||
className={`pointer-events-auto absolute flex h-7 w-7 items-center justify-center ${className}`}
|
||||
style={{ cursor }}
|
||||
onPointerDown={(event) => handleResizeStart(event, handle, selection)}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full border-2"
|
||||
style={{
|
||||
borderColor: token.colorBgContainer,
|
||||
background: token.colorPrimary,
|
||||
boxShadow: token.boxShadowTertiary
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cursorPoint && (
|
||||
<Card
|
||||
size="small"
|
||||
className="pointer-events-none fixed z-[1110]"
|
||||
style={{
|
||||
...magnifierStyle,
|
||||
width: 180,
|
||||
overflow: 'hidden',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
boxShadow: token.boxShadowSecondary
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
position: 'relative',
|
||||
width: 178,
|
||||
height: 128,
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
background: token.colorBgElevated
|
||||
}
|
||||
}}
|
||||
>
|
||||
<canvas ref={magnifierCanvasRef} className="block h-full w-full" />
|
||||
<div
|
||||
className="absolute inset-y-0 left-1/2 w-px"
|
||||
style={{ background: token.colorPrimary }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 h-px"
|
||||
style={{ background: token.colorPrimary }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<Draggable
|
||||
nodeRef={promptRef}
|
||||
bounds="parent"
|
||||
handle=".control-region-drag-handle"
|
||||
positionOffset={{ x: '-50%', y: '0%' }}
|
||||
>
|
||||
<div
|
||||
ref={promptRef}
|
||||
className="fixed left-1/2 top-5 z-[1120] max-w-[calc(100%-1rem)]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<div className="control-region-drag-handle flex cursor-move items-center gap-2">
|
||||
<HolderOutlined />
|
||||
<span>{t('screen.controlRegion.dragHint')}</span>
|
||||
</div>
|
||||
}
|
||||
style={{ boxShadow: token.boxShadowSecondary }}
|
||||
styles={{ header: { minHeight: 36 }, body: { padding: 8 } }}
|
||||
>
|
||||
<Space direction="vertical" size="small">
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!selection || !mediaSize}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t('screen.controlRegion.finish')}
|
||||
</Button>
|
||||
<Button size="small" onClick={cancelSelection}>
|
||||
{t('screen.controlRegion.cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
{error && <Alert type="error" showIcon message={error} />}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Draggable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
web/src/pages/desktop/screen/manual-region.tsx
Normal file
63
web/src/pages/desktop/screen/manual-region.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
manualInputRegionAtom,
|
||||
selectedOriginalResolutionAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
import { getCenteredInputRegionByAspectRatio, getMediaSize } from './geometry.ts';
|
||||
|
||||
export const ManualRegion = () => {
|
||||
const mode = useAtomValue(controlRegionModeAtom);
|
||||
const selected = useAtomValue(selectedOriginalResolutionAtom);
|
||||
const manualRegion = useAtomValue(manualInputRegionAtom);
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'manual') return;
|
||||
if (!selected) {
|
||||
setInputRegion(manualRegion);
|
||||
return;
|
||||
}
|
||||
|
||||
const [width, height] = selected.split('x').map(Number);
|
||||
if (manualRegion?.width === width && manualRegion.height === height) {
|
||||
setInputRegion(manualRegion);
|
||||
return;
|
||||
}
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen || !width || !height) {
|
||||
setInputRegion(null);
|
||||
return;
|
||||
}
|
||||
const target = screen;
|
||||
const update = () => {
|
||||
const mediaSize = getMediaSize(target);
|
||||
setInputRegion(
|
||||
mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null
|
||||
);
|
||||
};
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(target, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-media-width', 'data-media-height']
|
||||
});
|
||||
target.addEventListener('load', update);
|
||||
target.addEventListener('loadedmetadata', update);
|
||||
target.addEventListener('canplay', update);
|
||||
target.addEventListener('resize', update);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
target.removeEventListener('load', update);
|
||||
target.removeEventListener('loadedmetadata', update);
|
||||
target.removeEventListener('canplay', update);
|
||||
target.removeEventListener('resize', update);
|
||||
};
|
||||
}, [manualRegion, mode, selected, setInputRegion]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { stopFrameDetect } from '@/api/stream.ts';
|
||||
import { getFrameDetect } from '@/lib/localstorage.ts';
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { ScreenViewport } from './viewport.tsx';
|
||||
|
||||
export const Mjpeg = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [streamNonce, setStreamNonce] = useState(0);
|
||||
const streamURL = `${getBaseUrl('http')}/api/stream/mjpeg`;
|
||||
@@ -28,30 +28,18 @@ export const Mjpeg = () => {
|
||||
setStreamNonce((current) => current + 1);
|
||||
}, [resolution]);
|
||||
|
||||
useEffect(() => {
|
||||
const scale = storage.getVideoScale();
|
||||
if (scale) {
|
||||
setVideoScale(scale);
|
||||
}
|
||||
}, [setVideoScale]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden bg-black">
|
||||
<ScreenViewport>
|
||||
<img
|
||||
id="screen"
|
||||
className={clsx('block select-none touch-none', mouseStyle)}
|
||||
className={clsx('block touch-none select-none', mouseStyle)}
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
visibility: hasError ? 'hidden' : 'visible'
|
||||
}}
|
||||
src={streamSrc}
|
||||
onError={() => setHasError(true)}
|
||||
alt="screen"
|
||||
/>
|
||||
</div>
|
||||
</ScreenViewport>
|
||||
);
|
||||
};
|
||||
|
||||
99
web/src/pages/desktop/screen/viewport.tsx
Normal file
99
web/src/pages/desktop/screen/viewport.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { cloneElement, CSSProperties, ReactElement, useEffect, useRef, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom,
|
||||
videoScaleAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
type ViewportSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ScreenViewportProps = {
|
||||
children: ReactElement<{ style?: CSSProperties }>;
|
||||
};
|
||||
|
||||
export const ScreenViewport = ({ children }: ScreenViewportProps) => {
|
||||
const inputRegion = useAtomValue(inputRegionAtom);
|
||||
const mode = useAtomValue(controlRegionModeAtom);
|
||||
const selecting = useAtomValue(inputRegionSelectingAtom);
|
||||
const videoScale = useAtomValue(videoScaleAtom);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState<ViewportSize>({ width: 0, height: 0 });
|
||||
const cropped = mode !== 'off' && !!inputRegion && !selecting;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
setContainerSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
updateSize();
|
||||
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
let viewportWidth = containerSize.width;
|
||||
let viewportHeight = containerSize.height;
|
||||
if (cropped && inputRegion && containerSize.width > 0 && containerSize.height > 0) {
|
||||
const scale = Math.min(
|
||||
containerSize.width / inputRegion.width,
|
||||
containerSize.height / inputRegion.height
|
||||
);
|
||||
viewportWidth = inputRegion.width * scale;
|
||||
viewportHeight = inputRegion.height * scale;
|
||||
}
|
||||
|
||||
const mediaStyle: CSSProperties =
|
||||
cropped && inputRegion
|
||||
? {
|
||||
...children.props.style,
|
||||
position: 'absolute',
|
||||
left: -(inputRegion.left / inputRegion.width) * viewportWidth,
|
||||
top: -(inputRegion.top / inputRegion.height) * viewportHeight,
|
||||
width: (inputRegion.frameWidth / inputRegion.width) * viewportWidth,
|
||||
height: (inputRegion.frameHeight / inputRegion.height) * viewportHeight,
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
objectFit: 'fill',
|
||||
transform: 'none'
|
||||
}
|
||||
: {
|
||||
...children.props.style,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
transform: 'none'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
id="screen-viewport"
|
||||
className="relative overflow-hidden"
|
||||
data-cropped={cropped ? 'true' : 'false'}
|
||||
style={{
|
||||
width: viewportWidth || '100%',
|
||||
height: viewportHeight || '100%',
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center'
|
||||
}}
|
||||
>
|
||||
{cloneElement(children, { style: mediaStyle })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,3 +2,22 @@ export type Resolution = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type InputRegion = {
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ControlRegionMode = 'off' | 'auto' | 'manual';
|
||||
|
||||
export type OriginalResolution = Resolution;
|
||||
|
||||
export type ControlRegionConfig = Partial<InputRegion> & {
|
||||
mode: ControlRegionMode;
|
||||
resolutions?: OriginalResolution[];
|
||||
selectedResolution?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user