feat(web): enhance configurable input regions

Make input-region calibration work across MJPEG and H.264 video modes, including robust four-sided black-border detection and initial-selection edge snapping. Persist multiple reusable manual regions, keep manual and original-resolution choices mutually exclusive, migrate legacy settings, and localize the new controls for every supported language.
This commit is contained in:
watermeko
2026-08-24 12:06:38 +00:00
committed by Guoguo
parent 9e6d280826
commit 2ba45a2147
39 changed files with 536 additions and 179 deletions

View File

@@ -163,6 +163,17 @@ type InputRegion struct {
Height int `json:"height"`
Resolutions []OriginalResolution `json:"resolutions,omitempty"`
SelectedResolution string `json:"selectedResolution"`
Regions []ManualRegion `json:"regions,omitempty"`
SelectedRegion string `json:"selectedRegion"`
}
type ManualRegion struct {
FrameWidth int `json:"frameWidth"`
FrameHeight int `json:"frameHeight"`
Left int `json:"left"`
Top int `json:"top"`
Width int `json:"width"`
Height int `json:"height"`
}
type OriginalResolution struct {
@@ -180,6 +191,8 @@ type SetInputRegionReq struct {
Height *int `json:"height,omitempty"`
Resolutions *[]OriginalResolution `json:"resolutions,omitempty"`
SelectedResolution *string `json:"selectedResolution,omitempty"`
Regions *[]ManualRegion `json:"regions,omitempty"`
SelectedRegion *string `json:"selectedRegion,omitempty"`
}
type GetInputRegionRsp struct {

View File

@@ -99,7 +99,20 @@ func (s *Service) SetInputRegion(c *gin.Context) {
if req.SelectedResolution != nil {
region.SelectedResolution = *req.SelectedResolution
}
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
if req.Regions != nil {
if err := validateManualRegions(*req.Regions); err != nil {
rsp.ErrRsp(c, -1, "invalid manual regions")
return
}
region.Regions = *req.Regions
if len(region.Regions) == 0 {
clearLegacyManualRegion(region)
}
}
if req.SelectedRegion != nil {
region.SelectedRegion = *req.SelectedRegion
}
if err := validateSelections(*region); err != nil {
rsp.ErrRsp(c, -1, "invalid selected resolution")
return
}
@@ -127,6 +140,8 @@ func (s *Service) SetInputRegion(c *gin.Context) {
if previous != nil {
region.Resolutions = previous.Resolutions
region.SelectedResolution = previous.SelectedResolution
region.Regions = previous.Regions
region.SelectedRegion = previous.SelectedRegion
}
if req.Resolutions != nil {
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
@@ -138,7 +153,20 @@ func (s *Service) SetInputRegion(c *gin.Context) {
if req.SelectedResolution != nil {
region.SelectedResolution = *req.SelectedResolution
}
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
if req.Regions != nil {
if err := validateManualRegions(*req.Regions); err != nil {
rsp.ErrRsp(c, -1, "invalid manual regions")
return
}
region.Regions = *req.Regions
if len(region.Regions) == 0 {
clearLegacyManualRegion(&region)
}
}
if req.SelectedRegion != nil {
region.SelectedRegion = *req.SelectedRegion
}
if err := validateSelections(region); err != nil {
rsp.ErrRsp(c, -1, "invalid selected resolution")
return
}
@@ -176,10 +204,68 @@ func validateSelectedResolution(selected string, resolutions []proto.OriginalRes
return errors.New("selected resolution not found")
}
func validateManualRegions(regions []proto.ManualRegion) error {
seen := make(map[string]struct{}, len(regions))
for _, region := range regions {
if err := validateManualRegion(region); err != nil {
return err
}
key := manualRegionKey(region)
if _, ok := seen[key]; ok {
return errors.New("duplicate manual region")
}
seen[key] = struct{}{}
}
return nil
}
func validateManualRegion(region proto.ManualRegion) error {
return validateInputRegion(proto.InputRegion{
Mode: "manual",
FrameWidth: region.FrameWidth,
FrameHeight: region.FrameHeight,
Left: region.Left,
Top: region.Top,
Width: region.Width,
Height: region.Height,
})
}
func validateSelections(region proto.InputRegion) error {
if region.SelectedResolution != "" && region.SelectedRegion != "" {
return errors.New("only one input region source may be selected")
}
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
return err
}
if region.SelectedRegion == "" {
return nil
}
for _, item := range region.Regions {
if region.SelectedRegion == manualRegionKey(item) {
return nil
}
}
return errors.New("selected manual region not found")
}
func resolutionKey(resolution proto.OriginalResolution) string {
return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height)
}
func manualRegionKey(region proto.ManualRegion) string {
return fmt.Sprintf("%dx%d", region.Width, region.Height)
}
func clearLegacyManualRegion(region *proto.InputRegion) {
region.FrameWidth = 0
region.FrameHeight = 0
region.Left = 0
region.Top = 0
region.Width = 0
region.Height = 0
}
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 {
@@ -236,9 +322,29 @@ func readInputRegion(path string) (*proto.InputRegion, error) {
if region.Mode == "" {
region.Mode = "manual"
}
if len(region.Regions) == 0 && region.FrameWidth > 0 && region.FrameHeight > 0 {
manual := proto.ManualRegion{
FrameWidth: region.FrameWidth,
FrameHeight: region.FrameHeight,
Left: region.Left,
Top: region.Top,
Width: region.Width,
Height: region.Height,
}
region.Regions = []proto.ManualRegion{manual}
if region.SelectedResolution == "" {
region.SelectedRegion = manualRegionKey(manual)
}
}
if err := validateInputRegion(*region); err != nil {
return nil, nil
}
if err := validateManualRegions(region.Regions); err != nil {
return nil, nil
}
if err := validateSelections(*region); err != nil {
return nil, nil
}
return region, nil
}

View File

@@ -48,11 +48,17 @@ export function setInputRegion(region: InputRegion) {
return http.post('/api/vm/input-region', { mode: 'manual', ...region });
}
export function setInputRegionConfig(region: InputRegion, selectedResolution: string) {
export function setInputRegionConfig(
region: InputRegion,
regions: InputRegion[],
selectedRegion: string
) {
return http.post('/api/vm/input-region', {
mode: 'manual',
...region,
selectedResolution
regions,
selectedRegion,
selectedResolution: ''
});
}
@@ -66,17 +72,36 @@ export function setOriginalResolutions(mode: ControlRegionMode, resolutions: Ori
export function setOriginalResolutionConfig(
resolutions: OriginalResolution[],
selectedResolution: string
selectedResolution: string,
selectedRegion: string
) {
return http.post('/api/vm/input-region', {
mode: 'manual',
resolutions,
selectedResolution
selectedResolution,
selectedRegion
});
}
export function setSelectedOriginalResolution(selectedResolution: string) {
return http.post('/api/vm/input-region', { mode: 'manual', selectedResolution });
return http.post('/api/vm/input-region', {
mode: 'manual',
selectedResolution,
selectedRegion: ''
});
}
export function setManualRegions(
regions: InputRegion[],
selectedRegion: string,
selectedResolution = ''
) {
return http.post('/api/vm/input-region', {
mode: 'manual',
regions,
selectedRegion,
selectedResolution
});
}
// get memory limit

View File

@@ -73,9 +73,10 @@ const ca = {
autoWarning:
"El calibratge pot fallar quan l'aplicació de l'usuari tingui un fons completament negre.",
manual: 'Manual',
selectedResolution: "Resolució de l'àrea seleccionada",
unused: 'No utilitzada',
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.',

View File

@@ -73,9 +73,10 @@ const cz = {
auto: 'Automaticky',
autoWarning: 'Kalibrace může selhat, pokud má uživatelská aplikace zcela černé pozadí.',
manual: 'Ručně',
selectedResolution: 'Rozlišení vybrané oblasti',
unused: 'Nepoužito',
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.',

View File

@@ -73,9 +73,10 @@ const da = {
autoWarning:
'Kalibreringen kan mislykkes, hvis brugerprogrammet har en helt sort baggrund.',
manual: 'Manuel',
selectedResolution: 'Valgt områdeopløsning',
unused: 'Ikke i brug',
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.',

View File

@@ -76,9 +76,10 @@ const de = {
autoWarning:
'Die Kalibrierung kann fehlschlagen, wenn die Benutzeranwendung einen vollständig schwarzen Hintergrund hat.',
manual: 'Manuell',
selectedResolution: 'Auflösung des ausgewählten Bereichs',
unused: 'Nicht verwendet',
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.',

View File

@@ -74,9 +74,10 @@ const en = {
auto: 'Auto',
autoWarning: 'Calibration may fail when the user application has a pure black background.',
manual: 'Manual',
selectedResolution: 'Selected Area Resolution',
unused: 'Not used',
originalResolution: 'Original Resolution',
selectResolution: 'Select original resolution',
useSelectedArea: 'Use selected area resolution',
addResolution: 'Add custom resolution',
add: 'Add',
duplicateResolution: 'This resolution already exists.',

View File

@@ -74,9 +74,10 @@ const es = {
autoWarning:
'La calibración puede fallar si la aplicación del usuario tiene un fondo completamente negro.',
manual: 'Manual',
selectedResolution: 'Resolución del área seleccionada',
unused: 'Sin usar',
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.',

View File

@@ -76,9 +76,10 @@ const fr = {
autoWarning:
"L'étalonnage peut échouer si l'application utilisateur présente un arrière-plan entièrement noir.",
manual: 'Manuel',
selectedResolution: 'Résolution de la zone sélectionnée',
unused: 'Non utilisée',
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à.',

View File

@@ -75,9 +75,10 @@ const hu = {
autoWarning:
'A kalibrálás sikertelen lehet, ha a felhasználói alkalmazás háttere teljesen fekete.',
manual: 'Kézi',
selectedResolution: 'Kijelölt terület felbontása',
unused: 'Nincs használatban',
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.',

View File

@@ -73,9 +73,10 @@ const id = {
autoWarning:
'Kalibrasi mungkin gagal jika aplikasi pengguna menggunakan latar belakang hitam pekat.',
manual: 'Manual',
selectedResolution: 'Resolusi Area Terpilih',
unused: 'Tidak digunakan',
originalResolution: 'Resolusi Asli',
selectResolution: 'Pilih resolusi asli',
useSelectedArea: 'Gunakan resolusi area yang dipilih',
addResolution: 'Tambahkan resolusi khusus',
add: 'Tambah',
duplicateResolution: 'Resolusi ini sudah ada.',

View File

@@ -75,9 +75,10 @@ const it = {
autoWarning:
"La calibrazione potrebbe non riuscire se l'applicazione utente ha uno sfondo completamente nero.",
manual: 'Manuale',
selectedResolution: 'Risoluzione area selezionata',
unused: 'Non utilizzata',
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à.',

View File

@@ -73,9 +73,10 @@ const ja = {
autoWarning:
'ユーザーアプリケーションの背景が完全な黒の場合、補正に失敗することがあります。',
manual: '手動',
selectedResolution: '選択領域の解像度',
unused: '未使用',
originalResolution: '元の解像度',
selectResolution: '元の解像度を選択',
useSelectedArea: '選択領域の解像度を使用',
addResolution: 'カスタム解像度を追加',
add: '追加',
duplicateResolution: 'この解像度はすでに存在します。',

View File

@@ -71,9 +71,10 @@ const ko = {
auto: '자동',
autoWarning: '사용자 애플리케이션의 배경이 완전히 검은색이면 보정에 실패할 수 있습니다.',
manual: '수동',
selectedResolution: '선택 영역 해상도',
unused: '사용 안 함',
originalResolution: '원본 해상도',
selectResolution: '원본 해상도 선택',
useSelectedArea: '선택한 영역의 해상도 사용',
addResolution: '사용자 지정 해상도 추가',
add: '추가',
duplicateResolution: '이미 존재하는 해상도입니다.',

View File

@@ -74,9 +74,10 @@ const nb = {
autoWarning:
'Kalibreringen kan mislykkes hvis brukerprogrammet har en helt svart bakgrunn.',
manual: 'Manuell',
selectedResolution: 'Oppløsning for valgt område',
unused: 'Ikke i bruk',
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.',

View File

@@ -75,9 +75,10 @@ const nl = {
autoWarning:
'De kalibratie kan mislukken als de gebruikerstoepassing een volledig zwarte achtergrond heeft.',
manual: 'Handmatig',
selectedResolution: 'Resolutie van geselecteerd gebied',
unused: 'Niet gebruikt',
originalResolution: 'Oorspronkelijke resolutie',
selectResolution: 'Oorspronkelijke resolutie selecteren',
useSelectedArea: 'Resolutie van geselecteerd gebied gebruiken',
addResolution: 'Aangepaste resolutie toevoegen',
add: 'Toevoegen',
duplicateResolution: 'Deze resolutie bestaat al.',

View File

@@ -74,9 +74,10 @@ const pl = {
autoWarning:
'Kalibracja może się nie powieść, gdy aplikacja użytkownika ma całkowicie czarne tło.',
manual: 'Ręczna',
selectedResolution: 'Rozdzielczość zaznaczonego obszaru',
unused: 'Nieużywane',
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.',

View File

@@ -73,9 +73,10 @@ const pt_br = {
autoWarning:
'A calibração pode falhar se o aplicativo do usuário tiver um fundo totalmente preto.',
manual: 'Manual',
selectedResolution: 'Resolução da área selecionada',
unused: 'Não utilizado',
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.',

View File

@@ -73,9 +73,10 @@ const ru = {
autoWarning:
'Калибровка может завершиться неудачей, если фон пользовательского приложения полностью чёрный.',
manual: 'Вручную',
selectedResolution: 'Разрешение выбранной области',
unused: 'Не используется',
originalResolution: 'Исходное разрешение',
selectResolution: 'Выберите исходное разрешение',
useSelectedArea: 'Использовать разрешение выбранной области',
addResolution: 'Добавить пользовательское разрешение',
add: 'Добавить',
duplicateResolution: 'Такое разрешение уже существует.',

View File

@@ -70,9 +70,10 @@ const se = {
auto: 'Automatisk',
autoWarning: 'Kalibreringen kan misslyckas om användarprogrammet har en helsvart bakgrund.',
manual: 'Manuell',
selectedResolution: 'Valt områdesupplösning',
unused: 'Används inte',
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.',

View File

@@ -70,9 +70,10 @@ const th = {
auto: 'อัตโนมัติ',
autoWarning: 'การปรับเทียบอาจล้มเหลวเมื่อแอปพลิเคชันของผู้ใช้มีพื้นหลังสีดำสนิท',
manual: 'กำหนดเอง',
selectedResolution: 'ความละเอียดพื้นที่ที่เลือก',
unused: 'ไม่ได้ใช้',
originalResolution: 'ความละเอียดต้นฉบับ',
selectResolution: 'เลือกความละเอียดต้นฉบับ',
useSelectedArea: 'ใช้ความละเอียดของพื้นที่ที่เลือก',
addResolution: 'เพิ่มความละเอียดแบบกำหนดเอง',
add: 'เพิ่ม',
duplicateResolution: 'มีความละเอียดนี้อยู่แล้ว',

View File

@@ -74,9 +74,10 @@ const tr = {
autoWarning:
'Kullanıcı uygulamasının arka planı tamamen siyah olduğunda kalibrasyon başarısız olabilir.',
manual: 'Manuel',
selectedResolution: 'Seçili Alan Çözünürlüğü',
unused: 'Kullanılmıyor',
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.',

View File

@@ -74,9 +74,10 @@ const uk = {
autoWarning:
'Калібрування може завершитися невдало, якщо користувацька програма має повністю чорне тло.',
manual: 'Вручну',
selectedResolution: 'Роздільна здатність вибраної області',
unused: 'Не використовується',
originalResolution: 'Початкова роздільна здатність',
selectResolution: 'Виберіть початкову роздільну здатність',
useSelectedArea: 'Використати роздільну здатність вибраної області',
addResolution: 'Додати власну роздільну здатність',
add: 'Додати',
duplicateResolution: 'Така роздільна здатність уже існує.',

View File

@@ -73,9 +73,10 @@ const vi = {
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',
selectedResolution: 'Độ phân giải vùng đã chọn',
unused: 'Không sử dụ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.',

View File

@@ -72,9 +72,10 @@ const zh = {
auto: '自动',
autoWarning: '若用户程序为纯黑色背景,则可能校准出错。',
manual: '手动',
selectedResolution: '框选分辨率',
unused: '未使用',
originalResolution: '原始分辨率',
selectResolution: '选择原始分辨率',
useSelectedArea: '使用框选分辨率',
addResolution: '添加自定义分辨率',
add: '添加',
duplicateResolution: '该分辨率已存在。',

View File

@@ -69,9 +69,10 @@ const zh_tw = {
auto: '自動',
autoWarning: '當使用者應用程式使用全黑背景時,校正可能會失敗。',
manual: '手動',
selectedResolution: '框選解析度',
unused: '未使用',
originalResolution: '原始解析度',
selectResolution: '選擇原始解析度',
useSelectedArea: '使用所選區域的解析度',
addResolution: '新增自訂解析度',
add: '新增',
duplicateResolution: '此解析度已存在。',

View File

@@ -18,6 +18,8 @@ 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 manualRegionsAtom = atom<InputRegion[]>([]);
export const selectedManualRegionAtom = atom<string>('');
export const selectedOriginalResolutionAtom = atom<string>('');
// device-level control region mode; disabled by default

View File

@@ -13,7 +13,9 @@ import {
controlRegionModeAtom,
inputRegionAtom,
manualInputRegionAtom,
manualRegionsAtom,
resolutionAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom,
videoModeAtom
} from '@/jotai/screen.ts';
@@ -42,14 +44,11 @@ function getVideoMode() {
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
const cookieVideoMode = storage.getVideoMode();
if (cookieVideoMode) {
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
if (!cookieVideoMode || (cookieVideoMode === 'direct' && !window.VideoDecoder)) {
return defaultVideoMode;
}
return cookieVideoMode;
}
return defaultVideoMode;
return ['direct', 'h264', 'mjpeg'].includes(cookieVideoMode) ? cookieVideoMode : defaultVideoMode;
}
export const Desktop = () => {
@@ -64,6 +63,8 @@ export const Desktop = () => {
const [inputRegion, setInputRegion] = useAtom(inputRegionAtom);
const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom);
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
const setManualRegions = useSetAtom(manualRegionsAtom);
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom);
@@ -76,6 +77,8 @@ export const Desktop = () => {
setResolution(res);
setInputRegion(null);
setManualInputRegion(null);
setManualRegions([]);
setSelectedManualRegion('');
setSelectedOriginalResolution('');
setControlRegionModeState('off');
@@ -87,15 +90,24 @@ export const Desktop = () => {
? (config as InputRegion)
: null;
const selectedResolution = config?.selectedResolution || '';
const regions = config?.regions || [];
const selectedRegion = config?.selectedRegion || '';
const selectedManualRegion = regions.find(
(region) => `${region.width}x${region.height}` === selectedRegion
);
setManualInputRegion(manualRegion);
setManualRegions(regions);
setSelectedManualRegion(selectedRegion);
setSelectedOriginalResolution(selectedResolution);
setInputRegion(mode === 'manual' && !selectedResolution ? manualRegion : null);
setInputRegion(mode === 'manual' && selectedRegion ? selectedManualRegion || null : null);
setControlRegionModeState(mode);
})
.catch(() => {
setControlRegionModeState('off');
setInputRegion(null);
setManualInputRegion(null);
setManualRegions([]);
setSelectedManualRegion('');
setSelectedOriginalResolution('');
});
@@ -107,7 +119,9 @@ export const Desktop = () => {
setControlRegionModeState,
setInputRegion,
setManualInputRegion,
setManualRegions,
setResolution,
setSelectedManualRegion,
setSelectedOriginalResolution,
setVideoMode
]);
@@ -135,7 +149,7 @@ export const Desktop = () => {
clearTimeout(validationTimer);
}
validationTimer = setTimeout(() => {
const mediaSize = getMediaSize(target);
const mediaSize = getMediaSize(target, resolution);
if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) {
return;
}

View File

@@ -11,3 +11,23 @@ export const BitRateMap = new Map([
[3, 2000],
[4, 1000]
]);
export function getQualityMap(videoMode: string) {
if (videoMode === 'mjpeg') {
return QualityMap;
}
if (videoMode === 'direct' || videoMode === 'h264') {
return BitRateMap;
}
return null;
}
export function getScreenType(videoMode: string) {
if (videoMode === 'mjpeg') {
return 0;
}
if (videoMode === 'direct' || videoMode === 'h264') {
return 1;
}
return null;
}

View File

@@ -8,7 +8,7 @@ import * as ls from '@/lib/localstorage';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { MenuItem } from '@/components/menu-item.tsx';
import { BitRateMap, QualityMap } from './constants.ts';
import { getQualityMap, getScreenType } from './constants.ts';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Gop } from './gop.tsx';
@@ -23,12 +23,16 @@ export const Screen = () => {
const videoMode = useAtomValue(videoModeAtom);
const resolution = useAtomValue(resolutionAtom);
const isMjpeg = videoMode === 'mjpeg';
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(2);
const [gop, setGop] = useState(30);
useEffect(() => {
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
const screenType = getScreenType(videoMode);
if (screenType !== null) {
updateScreen('type', screenType);
}
updateScreen('resolution', resolution!.height);
updateQuality();
updateFps();
@@ -40,7 +44,9 @@ export const Screen = () => {
if (!cookieQuality) return;
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
const qualityMap = getQualityMap(videoMode);
const value = qualityMap?.get(key);
if (value === undefined) return;
const rsp = await updateScreen('quality', value);
if (rsp.code === 0) {
@@ -75,8 +81,8 @@ export const Screen = () => {
<Quality quality={quality} setQuality={setQuality} />
<Fps fps={fps} setFps={setFps} />
<Scale />
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />}
{videoMode === 'mjpeg' && <FrameDetect />}
{!isMjpeg && <Gop gop={gop} setGop={setGop} />}
{isMjpeg && <FrameDetect />}
<Reset />
</div>
);

View File

@@ -12,12 +12,13 @@ import {
Tooltip,
Typography
} from 'antd';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { ScanSearchIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
getInputRegion,
setManualRegions as saveManualRegions,
setControlRegionMode,
setOriginalResolutionConfig,
setSelectedOriginalResolution
@@ -34,6 +35,8 @@ import {
inputRegionAtom,
inputRegionSelectingAtom,
manualInputRegionAtom,
manualRegionsAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom
} from '@/jotai/screen.ts';
import { menuCloseSignalAtom } from '@/jotai/settings.ts';
@@ -44,9 +47,12 @@ const resolutionKey = ({ width, height }: ResolutionPreset) => `${width}x${heigh
export const OriginalResolution = () => {
const { t } = useTranslation();
const setInputRegion = useSetAtom(inputRegionAtom);
const manualInputRegion = useAtomValue(manualInputRegionAtom);
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
const [selectedResolution, setSelectedResolution] = useAtom(selectedOriginalResolutionAtom);
const [manualRegions, setManualRegions] = useAtom(manualRegionsAtom);
const [selectedManualRegion, setSelectedManualRegion] = useAtom(selectedManualRegionAtom);
const [selectedOriginalResolution, setSelectedOriginalResolutionState] = useAtom(
selectedOriginalResolutionAtom
);
const [mode, setMode] = useAtom(controlRegionModeAtom);
const setSelecting = useSetAtom(inputRegionSelectingAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
@@ -74,7 +80,9 @@ export const OriginalResolution = () => {
if (rsp.code === 0) {
const config = rsp.data as ControlRegionConfig;
setResolutions(config?.resolutions || []);
setSelectedResolution(config?.selectedResolution || '');
setManualRegions(config?.regions || []);
setSelectedManualRegion(config?.selectedRegion || '');
setSelectedOriginalResolutionState(config?.selectedResolution || '');
setManualInputRegion(
isValidInputRegion(config as InputRegion) ? (config as InputRegion) : null
);
@@ -82,18 +90,41 @@ export const OriginalResolution = () => {
return rsp;
}
async function applyResolution(key: string) {
async function applyOriginalResolution(key: string) {
const rsp = await setSelectedOriginalResolution(key);
if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed'));
return;
}
setSelectedResolution(key);
setSelectedOriginalResolutionState(key);
setSelectedManualRegion('');
if (!key) setInputRegion(null);
setIsPopoverOpen(true);
}
async function saveResolutions(next: ResolutionPreset[], nextSelected = selectedResolution) {
const rsp = await setOriginalResolutionConfig(next, nextSelected);
async function applyManualRegion(key: string) {
const rsp = await saveManualRegions(manualRegions, key);
if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed'));
return;
}
setSelectedManualRegion(key);
setSelectedOriginalResolutionState('');
setInputRegion(
manualRegions.find((region) => `${region.width}x${region.height}` === key) || null
);
setIsPopoverOpen(true);
}
async function saveResolutions(
next: ResolutionPreset[],
nextSelected = selectedOriginalResolution
) {
const rsp = await setOriginalResolutionConfig(
next,
nextSelected,
nextSelected ? '' : selectedManualRegion
);
if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed'));
return false;
@@ -122,7 +153,7 @@ export const OriginalResolution = () => {
async function deleteResolution(index: number) {
if (index < 0 || index >= resolutions.length) return;
const deleted = resolutionKey(resolutions[index]);
const nextSelected = selectedResolution === deleted ? '' : selectedResolution;
const nextSelected = selectedOriginalResolution === deleted ? '' : selectedOriginalResolution;
if (
await saveResolutions(
resolutions.filter((_, itemIndex) => itemIndex !== index),
@@ -130,12 +161,29 @@ export const OriginalResolution = () => {
)
) {
if (!nextSelected) {
setSelectedResolution('');
setInputRegion(manualInputRegion);
setSelectedOriginalResolutionState('');
setInputRegion(null);
}
}
}
async function deleteManualRegion(index: number) {
if (index < 0 || index >= manualRegions.length) return;
const deleted = `${manualRegions[index].width}x${manualRegions[index].height}`;
const nextSelected = selectedManualRegion === deleted ? '' : selectedManualRegion;
const next = manualRegions.filter((_, itemIndex) => itemIndex !== index);
const rsp = await saveManualRegions(next, nextSelected, selectedOriginalResolution);
if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed'));
return;
}
setManualRegions(next);
if (!nextSelected) {
setSelectedManualRegion('');
setInputRegion(null);
}
}
async function updateMode(nextMode: ControlRegionMode) {
const rsp = await setControlRegionMode(nextMode);
if (rsp.code !== 0) {
@@ -148,9 +196,14 @@ export const OriginalResolution = () => {
const manualRegion = isValidInputRegion(config.data as InputRegion)
? (config.data as InputRegion)
: null;
const selectedRegion = (config.data as ControlRegionConfig)?.selectedRegion || '';
const selectedResolution = (config.data as ControlRegionConfig)?.selectedResolution || '';
const regions = (config.data as ControlRegionConfig)?.regions || [];
setManualInputRegion(manualRegion);
setInputRegion(
(config.data as ControlRegionConfig)?.selectedResolution ? null : manualRegion
selectedResolution
? null
: regions.find((region) => `${region.width}x${region.height}` === selectedRegion) || null
);
} else {
setInputRegion(null);
@@ -195,19 +248,58 @@ export const OriginalResolution = () => {
<Button block type="primary" icon={<ScanSearchIcon size={14} />} onClick={selectArea}>
{t('screen.controlRegion.select')}
</Button>
<Typography.Text>{t('screen.controlRegion.selectedResolution')}</Typography.Text>
<Select
className="w-full"
value={selectedManualRegion}
options={[
{ label: t('screen.controlRegion.unused'), value: '' },
...manualRegions.map((region) => ({
label: `${region.width}x${region.height}`,
value: `${region.width}x${region.height}`
}))
]}
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();
deleteManualRegion(
manualRegions.findIndex(
(region) => `${region.width}x${region.height}` === option.value
)
);
}}
/>
)}
</div>
)}
onChange={applyManualRegion}
/>
<Typography.Text>{t('screen.controlRegion.originalResolution')}</Typography.Text>
<Space.Compact block>
<Select
className="w-full"
value={selectedResolution}
value={selectedOriginalResolution}
placeholder={t('screen.controlRegion.selectResolution')}
getPopupContainer={(trigger) => trigger.parentElement || document.body}
options={resolutions
.map((resolution) => ({
options={[
{ label: t('screen.controlRegion.unused'), value: '' },
...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>
@@ -233,7 +325,7 @@ export const OriginalResolution = () => {
)}
</div>
)}
onChange={applyResolution}
onChange={applyOriginalResolution}
/>
<Tooltip title={t('screen.controlRegion.addResolution')}>
<Button icon={<PlusOutlined />} onClick={() => setIsAddOpen(true)} />

View File

@@ -7,7 +7,7 @@ import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
import { videoModeAtom } from '@/jotai/screen.ts';
import { BitRateMap, QualityMap } from './constants.ts';
import { getQualityMap } from './constants.ts';
type QualityProps = {
quality: number;
@@ -26,7 +26,11 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
];
async function update(key: number) {
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
const qualityMap = getQualityMap(videoMode);
const value = qualityMap?.get(key);
if (value === undefined) {
return;
}
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {

View File

@@ -4,14 +4,16 @@ import { useAtomValue, useSetAtom } from 'jotai';
import {
controlRegionModeAtom,
inputRegionAtom,
inputRegionSelectingAtom
inputRegionSelectingAtom,
resolutionAtom
} from '@/jotai/screen.ts';
import { detectFrameContent, getMediaSize, isMediaReady } from './geometry.ts';
import { detectFrameContent, getMediaSize } from './geometry.ts';
export const AutoRegion = () => {
const mode = useAtomValue(controlRegionModeAtom);
const selecting = useAtomValue(inputRegionSelectingAtom);
const resolution = useAtomValue(resolutionAtom);
const setInputRegion = useSetAtom(inputRegionAtom);
useEffect(() => {
@@ -19,18 +21,25 @@ export const AutoRegion = () => {
return;
}
const screen = document.getElementById('screen');
if (!screen) return;
const target = screen;
let stopped = false;
let target: Element | null = null;
let candidate = '';
let confirmations = 0;
const detect = () => {
if (stopped || !isMediaReady(target)) return;
const mediaSize = getMediaSize(target);
if (stopped) return;
const screen = document.getElementById('screen');
if (!screen) return;
if (screen !== target) {
target = screen;
candidate = '';
confirmations = 0;
}
const mediaSize = getMediaSize(screen, resolution);
if (!mediaSize) return;
const content = detectFrameContent(target, mediaSize);
const content = detectFrameContent(screen, mediaSize);
const key = [
mediaSize.width,
mediaSize.height,
@@ -65,7 +74,7 @@ export const AutoRegion = () => {
stopped = true;
window.clearInterval(timer);
};
}, [mode, selecting, setInputRegion]);
}, [mode, resolution, selecting, setInputRegion]);
useEffect(() => {
if (mode !== 'auto') setInputRegion(null);

View File

@@ -15,6 +15,10 @@ export type FrameContent = {
export type RenderedMediaRect = FrameContent;
const aspectRatioTolerance = 0.01;
const mediaWidthAttribute = 'data-media-width';
const mediaHeightAttribute = 'data-media-height';
const blackPixelThreshold = 32;
const darkSampleRatio = 0.75;
export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
left: 0,
@@ -23,7 +27,13 @@ export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
height: mediaSize.height
});
export function getMediaSize(screen: Element): MediaSize | null {
export function getMediaSize(screen: Element, fallback?: MediaSize | null): MediaSize | null {
const dataWidth = Number(screen.getAttribute(mediaWidthAttribute));
const dataHeight = Number(screen.getAttribute(mediaHeightAttribute));
if (dataWidth > 0 && dataHeight > 0) {
return { width: dataWidth, height: dataHeight };
}
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
return { width: screen.videoWidth, height: screen.videoHeight };
}
@@ -32,28 +42,20 @@ export function getMediaSize(screen: Element): MediaSize | null {
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;
return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null;
}
export function isMediaReady(screen: Element) {
if (screen instanceof HTMLVideoElement) {
return screen.readyState >= 2 && screen.videoWidth > 0 && screen.videoHeight > 0;
return screen.readyState >= 2 && !!getMediaSize(screen);
}
if (screen instanceof HTMLImageElement) {
return screen.complete && screen.naturalWidth > 0 && screen.naturalHeight > 0;
return !!getMediaSize(screen);
}
if (screen instanceof HTMLCanvasElement) {
return Number(screen.dataset.mediaWidth) > 0 && Number(screen.dataset.mediaHeight) > 0;
return !!getMediaSize(screen);
}
return false;
@@ -73,20 +75,33 @@ export function detectFrameContent(screen: Element, mediaSize: MediaSize): Frame
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));
let top = findBorderInset(pixels, width, height, true, 'vertical');
let bottom = findBorderInset(pixels, width, height, false, 'vertical');
let verticalBorder = isSymmetricBorder(top, bottom, height);
const left = findBorderInset(
pixels,
width,
height,
true,
'horizontal',
verticalBorder ? top : 0,
verticalBorder ? height - bottom : height
);
const right = findBorderInset(
pixels,
width,
height,
false,
'horizontal',
verticalBorder ? top : 0,
verticalBorder ? height - bottom : height
);
const horizontalBorder = isSymmetricBorder(left, right, width);
if (horizontalBorder) {
top = findBorderInset(pixels, width, height, true, 'vertical', left, width - right);
bottom = findBorderInset(pixels, width, height, false, 'vertical', left, width - right);
verticalBorder = isSymmetricBorder(top, bottom, height);
}
const frameLeft = horizontalBorder ? (left / width) * mediaSize.width : 0;
const frameRight = horizontalBorder ? (right / width) * mediaSize.width : 0;
const frameTop = verticalBorder ? (top / height) * mediaSize.height : 0;
@@ -128,28 +143,43 @@ function findBorderInset(
width: number,
height: number,
fromStart: boolean,
axis: 'horizontal' | 'vertical'
axis: 'horizontal' | 'vertical',
sampleStart = 0,
sampleEnd = axis === 'horizontal' ? height : width
) {
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 sampleSize = Math.max(1, sampleEnd - sampleStart);
const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map(
(ratio) => sampleStart + 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 darkSamples = lines.filter((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;
});
return (
pixels[index] <= blackPixelThreshold &&
pixels[index + 1] <= blackPixelThreshold &&
pixels[index + 2] <= blackPixelThreshold
);
}).length;
const black = darkSamples / lines.length >= darkSampleRatio;
if (!black) break;
inset = offset + 1;
}
return inset;
}
function isSymmetricBorder(start: number, end: number, size: number) {
return (
start >= Math.max(4, Math.round(size * 0.02)) &&
end >= Math.max(4, Math.round(size * 0.02)) &&
Math.abs(start - end) <= Math.max(4, Math.round(size * 0.05))
);
}
export function getRenderedMediaRect(
elementRect: DOMRect,
mediaSize: MediaSize

View File

@@ -17,5 +17,9 @@ export const Screen = () => {
return <H264Direct />;
}
if (videoMode === 'h264') {
return <H264Webrtc />;
}
return null;
};

View File

@@ -12,6 +12,9 @@ import {
inputRegionAtom,
inputRegionSelectingAtom,
manualInputRegionAtom,
manualRegionsAtom,
resolutionAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom,
videoScaleAtom
} from '@/jotai/screen.ts';
@@ -75,6 +78,7 @@ const resizeHandles: Array<{
];
const minimumSelectionSize = 4;
const edgeSnapDistance = 8;
export const InputRegionOverlay = () => {
const { t } = useTranslation();
@@ -82,6 +86,9 @@ export const InputRegionOverlay = () => {
const [selecting, setSelecting] = useAtom(inputRegionSelectingAtom);
const [, setInputRegionState] = useAtom(inputRegionAtom);
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
const [manualRegions, setManualRegions] = useAtom(manualRegionsAtom);
const resolution = useAtomValue(resolutionAtom);
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
const videoScale = useAtomValue(videoScaleAtom);
const setVideoScale = useSetAtom(videoScaleAtom);
@@ -151,17 +158,15 @@ export const InputRegionOverlay = () => {
return;
}
const screen = document.getElementById('screen');
if (!screen) {
function updateFrame() {
const target = document.getElementById('screen');
if (!target) {
setFrameRect(null);
setMediaSize(null);
frameRectRef.current = null;
return;
}
const target = screen;
function updateFrame() {
const nextMediaSize = getMediaSize(target);
const nextMediaSize = getMediaSize(target, resolution);
const bounds = target.getBoundingClientRect();
if (!nextMediaSize || bounds.width <= 0 || bounds.height <= 0) {
setFrameRect(null);
@@ -192,31 +197,16 @@ export const InputRegionOverlay = () => {
}
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);
const timer = window.setInterval(updateFrame, 250);
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);
window.clearInterval(timer);
};
}, [selecting, videoScale]);
}, [resolution, selecting, videoScale]);
useEffect(() => {
if (!selecting) {
@@ -345,12 +335,20 @@ export const InputRegionOverlay = () => {
return null;
}
const right = frameRect.left + frameRect.width;
const bottom = frameRect.top + frameRect.height;
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))
x: snapToEdge(Math.max(frameRect.left, Math.min(right, x)), frameRect.left, right),
y: snapToEdge(Math.max(frameRect.top, Math.min(bottom, y)), frameRect.top, bottom)
};
}
function snapToEdge(value: number, start: number, end: number) {
if (Math.abs(value - start) <= edgeSnapDistance) return start;
if (Math.abs(value - end) <= edgeSnapDistance) return end;
return value;
}
function updateSelection(x: number, y: number) {
const start = dragStartRef.current;
const point = clampPoint(x, y);
@@ -448,17 +446,19 @@ export const InputRegionOverlay = () => {
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
) {
function updateHoverCursorPoint(x: number, y: number) {
if (!frameRect || selection) {
updateCursorPoint(x, y);
return;
}
const point = clampPoint(x, y);
if (point) {
updateCursorPoint(point.x, point.y, true);
}
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
const point = clampPoint(event.clientX, event.clientY);
if (!point) {
return;
@@ -480,11 +480,14 @@ export const InputRegionOverlay = () => {
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);
} else if (dragging) {
const point = clampPoint(event.clientX, event.clientY);
if (point) {
updateCursorPoint(point.x, point.y, true);
updateSelection(point.x, point.y);
}
} else {
updateHoverCursorPoint(event.clientX, event.clientY);
}
if (moveRef.current) {
updateMovedSelection(event.clientX, event.clientY);
@@ -575,14 +578,20 @@ export const InputRegionOverlay = () => {
width: Math.max(1, right - left),
height: Math.max(1, bottom - top)
};
const key = `${region.width}x${region.height}`;
const regions = manualRegions.some((item) => `${item.width}x${item.height}` === key)
? manualRegions.map((item) => (`${item.width}x${item.height}` === key ? region : item))
: [...manualRegions, region];
const rsp = await setInputRegionConfig(region, '');
const rsp = await setInputRegionConfig(region, regions, key);
if (rsp.code !== 0) {
setError(t('screen.controlRegion.saveFailed'));
return;
}
setManualInputRegion(region);
setManualRegions(regions);
setSelectedManualRegion(key);
setSelectedOriginalResolution('');
setInputRegionState(region);
cursorPointRef.current = null;

View File

@@ -4,7 +4,9 @@ import { useAtomValue, useSetAtom } from 'jotai';
import {
controlRegionModeAtom,
inputRegionAtom,
manualInputRegionAtom,
manualRegionsAtom,
resolutionAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom
} from '@/jotai/screen.ts';
@@ -12,52 +14,47 @@ import { getCenteredInputRegionByAspectRatio, getMediaSize } from './geometry.ts
export const ManualRegion = () => {
const mode = useAtomValue(controlRegionModeAtom);
const selected = useAtomValue(selectedOriginalResolutionAtom);
const manualRegion = useAtomValue(manualInputRegionAtom);
const selectedOriginal = useAtomValue(selectedOriginalResolutionAtom);
const selectedManual = useAtomValue(selectedManualRegionAtom);
const manualRegions = useAtomValue(manualRegionsAtom);
const resolution = useAtomValue(resolutionAtom);
const setInputRegion = useSetAtom(inputRegionAtom);
useEffect(() => {
if (mode !== 'manual') return;
if (!selected) {
setInputRegion(manualRegion);
if (selectedManual) {
setInputRegion(
manualRegions.find((region) => `${region.width}x${region.height}` === selectedManual) ||
null
);
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) {
if (!selectedOriginal) {
setInputRegion(null);
return;
}
const target = screen;
const [width, height] = selectedOriginal.split('x').map(Number);
if (!width || !height) {
setInputRegion(null);
return;
}
const update = () => {
const mediaSize = getMediaSize(target);
const target = document.getElementById('screen');
if (!target) return;
const mediaSize = getMediaSize(target, resolution);
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);
const timer = window.setInterval(update, 250);
return () => {
observer.disconnect();
target.removeEventListener('load', update);
target.removeEventListener('loadedmetadata', update);
target.removeEventListener('canplay', update);
target.removeEventListener('resize', update);
window.clearInterval(timer);
};
}, [manualRegion, mode, selected, setInputRegion]);
}, [manualRegions, mode, resolution, selectedManual, selectedOriginal, setInputRegion]);
return null;
};

View File

@@ -20,4 +20,6 @@ export type ControlRegionConfig = Partial<InputRegion> & {
mode: ControlRegionMode;
resolutions?: OriginalResolution[];
selectedResolution?: string;
regions?: InputRegion[];
selectedRegion?: string;
};