feat(input): add configurable Steam button radial controls

This commit is contained in:
toorux committed 2026-10-19 22:51:58 +08:00
1 parent 109160ac5b
commit b3182c46ec
12 files changed
+340 -4

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Keep SteamVR's click and double-click actions while Framely handles hold."""
import hashlib
import json
import os
from pathlib import Path
import sys
from urllib.parse import urlencode
from urllib.request import Request, urlopen
APP = "openvr.component.vrcompositor"
CONTROLLER = "frame_controller"
BASE = "http://127.0.0.1:27062"
HEADERS = {
"Origin": BASE,
"Referer": BASE + "/dashboard/controllerbinding.html",
}
DIRECTORY = Path.home() / ".local/share/framely/input"
BINDING = DIRECTORY / "steam-system-menu.json"
META = DIRECTORY / "steam-system-menu-source.json"
def request(path, params=None, body=None):
if params:
path += "?" + urlencode(params)
data = None if body is None else json.dumps(body).encode()
headers = dict(HEADERS)
if data is not None:
headers["Content-Type"] = "application/json"
with urlopen(Request(BASE + path, data=data, headers=headers), timeout=3) as response:
return json.load(response)
def current_url():
data = request("/input/getactions.json", {"app_key": APP})
return data["current_binding_url"][CONTROLLER]
def select(url):
result = request(
"/input/selectconfig.action",
body={"app_key": APP, "controller_type": CONTROLLER, "url": url},
)
if not result.get("success"):
raise RuntimeError(f"SteamVR rejected controller binding: {result}")
def source_binding(url):
result = request(
"/input/loadbindingfromurl.json",
{"binding_url": url, "controller_type": CONTROLLER, "app_key": APP},
)
if "binding_config" not in result:
raise RuntimeError(f"SteamVR could not load controller binding: {result}")
return result["binding_config"]
def without_recenter(binding):
# Preserve every other compositor action, including click, double and
# screenshot chords. A user's unrelated quick-recenter sources survive.
result = json.loads(json.dumps(binding))
sources = result.get("bindings", {}).get("/actions/quickrecenter", {}).get("sources", [])
kept = [
source for source in sources
if source.get("path") not in (
"/user/hand/left/input/system",
"/user/hand/right/input/system",
) or "long" not in source.get("inputs", {})
]
result["bindings"]["/actions/quickrecenter"]["sources"] = kept
return result, len(kept) != len(sources)
def atomic_json(path, value):
DIRECTORY.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".new")
with temporary.open("w") as out:
json.dump(value, out, ensure_ascii=False, separators=(",", ":"))
out.flush()
os.fsync(out.fileno())
os.replace(temporary, path)
def main():
active = current_url()
ours = BINDING.resolve().as_uri()
metadata = json.loads(META.read_text()) if META.exists() else {}
if len(sys.argv) > 1 and sys.argv[1] == "--restore":
if active == ours and metadata.get("source_url"):
select(metadata["source_url"])
return 10
return 0
source_url = metadata.get("source_url") if active == ours else active
if not source_url:
raise RuntimeError("SteamVR did not report a Frame controller binding")
source = source_binding(source_url)
patched, changed = without_recenter(source)
if not changed:
return 0
digest = hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()
if active == ours and metadata.get("source_hash") == digest and BINDING.exists():
return 0
atomic_json(BINDING, patched)
atomic_json(META, {"source_url": source_url, "source_hash": digest})
select(ours)
return 10 # SteamVR caches the old long-press action until it restarts.
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as error:
print(f"SteamVR system-menu binding: {error}", file=sys.stderr)
sys.exit(1)
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <algorithm>
#include <chrono>
#include <cmath>
#include <optional>
enum class SystemRadialDirection {Center,Up,UpRight,Right,DownRight,Down,DownLeft,Left,UpLeft};
enum class SystemRadialEvent {Idle,Begin,Open,Change,Cancel,Commit};
struct SystemRadial {
using Clock=std::chrono::steady_clock;
bool active=false,open=false;
int hand=-1;
Clock::time_point began{};
SystemRadialDirection direction=SystemRadialDirection::Center;
static constexpr auto hold=std::chrono::seconds(1);
static constexpr float deadzone=.09f;
float progress(Clock::time_point now) const {
return active?std::clamp(std::chrono::duration<float>(now-began).count(),0.f,1.f):0.f;
}
SystemRadialEvent update(int input_hand,bool valid,bool down,float right,float up,Clock::time_point now,std::optional<SystemRadialDirection> hover=std::nullopt) {
if(!active) {
if(!valid||!down)return SystemRadialEvent::Idle;
active=true;open=false;hand=input_hand;began=now;direction=SystemRadialDirection::Center;
return SystemRadialEvent::Begin;
}
if(input_hand!=hand)return SystemRadialEvent::Idle;
if(!valid) {active=false;open=false;hand=-1;return SystemRadialEvent::Cancel;}
if(!down) {
const bool commit=open||now-began>=hold;
active=false;open=false;hand=-1;
return commit?SystemRadialEvent::Commit:SystemRadialEvent::Cancel;
}
if(!open&&now-began>=hold){open=true;return SystemRadialEvent::Open;}
if(open) {
auto next=hover.value_or(select(right,up));
if(next!=direction){direction=next;return SystemRadialEvent::Change;}
}
return SystemRadialEvent::Idle;
}
static SystemRadialDirection select(float right,float up) {
if(right*right+up*up<deadzone*deadzone)return SystemRadialDirection::Center;
constexpr float sector=.78539816339f;
int index=(int)std::lround(std::atan2(right,up)/sector);
if(index<0)index+=8;
switch(index%8) {
case 0:return SystemRadialDirection::Up;
case 1:return SystemRadialDirection::UpRight;
case 2:return SystemRadialDirection::Right;
case 3:return SystemRadialDirection::DownRight;
case 4:return SystemRadialDirection::Down;
case 5:return SystemRadialDirection::DownLeft;
case 6:return SystemRadialDirection::Left;
default:return SystemRadialDirection::UpLeft;
}
}
// Match the four visible 124x82 slots and 112px center in the 720px overlay.
// A ray inside the painted button always selects it, including its border.
static std::optional<SystemRadialDirection> hit(float x,float y) {
const float dx=x-360.f,dy=y-360.f;
if(dx*dx+dy*dy<=56.f*56.f)return SystemRadialDirection::Center;
if(std::abs(dx)<=62.f&&std::abs(dy+177.f)<=41.f)return SystemRadialDirection::Up;
if(std::abs(dx-177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Right;
if(std::abs(dx)<=62.f&&std::abs(dy-177.f)<=41.f)return SystemRadialDirection::Down;
if(std::abs(dx+177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Left;
return std::nullopt;
}
};
+3
View File
@@ -5,6 +5,9 @@ root=/var/lib/framely
previous=$(cat "$root/previous-release")
[[ $previous == releases/* && $previous != *..* && -d $root/$previous ]] || { echo 'No previous release.' >&2; exit 1; }
current=$(readlink "$root/current")
if [[ ! -f $root/$previous/share/input/steam-system-menu.py && -f $root/current/system-menu-restore.sh ]]; then
bash "$root/current/system-menu-restore.sh"
fi
systemctl stop framely-session.service framely.service
ln -s "$previous" "$root/current.rollback"
mv -Tf "$root/current.rollback" "$root/current"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
root=/var/lib/framely
helper=$root/current/share/input/steam-system-menu.py
[[ -f $helper && -f $root/steam-user ]] || exit 0
steam_user=$(cat "$root/steam-user")
steam_uid=$(id -u "$steam_user")
steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
set +e
runuser -u "$steam_user" -- env HOME="$steam_home" XDG_RUNTIME_DIR="/run/user/$steam_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$steam_uid/bus" python3 "$helper" --restore
result=$?
set -e
if [[ $result == 10 ]]; then
systemctl --user --machine="$steam_user@" restart steamvr.service
elif [[ $result != 0 ]]; then
echo 'Could not restore the original SteamVR system-button binding; start SteamVR and retry.' >&2
exit "$result"
fi
+3
View File
@@ -13,6 +13,9 @@ restore_session() {
exit "$code"
}
trap restore_session ERR
if [[ -f $root/current/system-menu-restore.sh ]]; then
bash "$root/current/system-menu-restore.sh"
fi
systemctl start framely.service
ready=false
for attempt in {1..30}; do
+3
View File
@@ -199,6 +199,8 @@ pub struct LauncherSettings {
#[serde(default = "yes")]
pub prioritize_ui: bool,
#[serde(default = "yes")]
pub system_radial_menu: bool,
#[serde(default = "yes")]
pub primary_trigger: bool,
#[serde(default = "yes")]
pub menu_auto_close: bool,
@@ -264,6 +266,7 @@ impl Default for LauncherSettings {
fn default() -> Self {
Self {
prioritize_ui: true,
system_radial_menu: true,
primary_trigger: true,
menu_auto_close: true,
menu_timeout_seconds: 10,
+31
View File
@@ -0,0 +1,31 @@
#include "system_radial.h"
#include <cassert>
#include <iostream>
int main(){
using Clock=SystemRadial::Clock;
auto start=Clock::now();
SystemRadial menu;
assert(menu.update(0,true,true,0,0,start)==SystemRadialEvent::Begin);
assert(menu.update(0,true,false,0,0,start+std::chrono::milliseconds(120))==SystemRadialEvent::Cancel);
assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(2))==SystemRadialEvent::Begin);
assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(3))==SystemRadialEvent::Open);
assert(menu.update(1,true,true,0,.15f,start+std::chrono::milliseconds(3100))==SystemRadialEvent::Change);
assert(menu.direction==SystemRadialDirection::Up);
assert(menu.update(1,true,false,0,.15f,start+std::chrono::milliseconds(3200))==SystemRadialEvent::Commit);
assert(SystemRadial::select(.2f,0)==SystemRadialDirection::Right);
assert(SystemRadial::select(-.2f,0)==SystemRadialDirection::Left);
assert(SystemRadial::select(0,-.2f)==SystemRadialDirection::Down);
assert(SystemRadial::select(.03f,.01f)==SystemRadialDirection::Center);
assert(SystemRadial::hit(475,319)==SystemRadialDirection::Right);
assert(SystemRadial::hit(599,401)==SystemRadialDirection::Right);
assert(SystemRadial::hit(360,183)==SystemRadialDirection::Up);
assert(SystemRadial::hit(183,360)==SystemRadialDirection::Left);
assert(SystemRadial::hit(360,537)==SystemRadialDirection::Down);
assert(!SystemRadial::hit(470,315));
SystemRadial pointer;
assert(pointer.update(0,true,true,0,0,start)==SystemRadialEvent::Begin);
assert(pointer.update(0,true,true,0,0,start+std::chrono::seconds(1))==SystemRadialEvent::Open);
assert(pointer.update(0,true,true,0,0,start+std::chrono::milliseconds(1050),SystemRadial::hit(475,319))==SystemRadialEvent::Change);
assert(pointer.direction==SystemRadialDirection::Right);
std::cout<<"system radial: passed\n";
}
+37
View File
@@ -0,0 +1,37 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
"steam_system_menu",
Path(__file__).parents[1] / "native/input/steam-system-menu.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class BindingTests(unittest.TestCase):
def test_removes_only_system_long_recenter(self):
binding = {
"bindings": {
"/actions/system": {"sources": [{
"path": "/user/hand/left/input/system",
"inputs": {"click": {"output": "dashboard"}, "double": {"output": "room"}},
}]},
"/actions/quickrecenter": {"sources": [
{"path": "/user/hand/left/input/system", "inputs": {"long": {"output": "recenter"}}},
{"path": "/user/hand/right/input/system", "inputs": {"long": {"output": "recenter"}}},
{"path": "/user/hand/left/input/grip", "inputs": {"long": {"output": "recenter"}}},
]},
}
}
patched, changed = module.without_recenter(binding)
self.assertTrue(changed)
self.assertEqual(len(patched["bindings"]["/actions/quickrecenter"]["sources"]), 1)
self.assertEqual(patched["bindings"]["/actions/system"], binding["bindings"]["/actions/system"])
self.assertEqual(len(binding["bindings"]["/actions/quickrecenter"]["sources"]), 3)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cd "$base"
mkdir -p target/tests
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth; do
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth system_radial; do
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test"
"target/tests/$test"
done
+3 -3
View File
@@ -12,8 +12,8 @@ import './launcher.css';
import {installLauncherGaze} from './launcher-gaze';
import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
export type LauncherConfig={prioritizeUi?:boolean;systemRadialMenu?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,systemRadialMenu:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];id:number;name:string;icon?:string;iconFit?:string;kind?:string};
function launcherCells(total:number){
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
@@ -30,7 +30,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('长按 Steam 键打开 Framely 菜单')} description={t('关闭后恢复 Steam 原生长按行为。切换时 SteamVR 会重新启动。')} checked={config.systemRadialMenu??true} disabled={busy} onChange={systemRadialMenu=>void save({systemRadialMenu})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
}
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
+14
View File
@@ -0,0 +1,14 @@
html:has(.system-radial),body:has(.system-radial),#root:has(.system-radial){width:100%;height:100%;margin:0;background:transparent!important;overflow:hidden}
.system-radial{position:relative;display:grid;place-items:center;width:100%;height:100%;color:#fff;font-family:system-ui,"Noto Sans CJK SC",sans-serif;user-select:none;pointer-events:none}
.system-radial-charge{position:relative;width:86px;height:86px;border-radius:50%;background:rgba(0,0,0,.58)}
.system-radial-charge-ring{position:absolute;inset:0;width:86px;height:86px;fill:none;stroke-width:3;transform:rotate(-90deg)}
.system-radial-charge-track{stroke:rgba(255,255,255,.24)}
.system-radial-charge-progress{stroke:#fff;stroke-linecap:round}
.system-radial-face{position:relative;width:500px;height:500px}
.system-radial-slot{--radius:177px;position:absolute;left:50%;top:50%;width:124px;height:82px;transform:translate(-50%,-50%) rotate(var(--angle)) translateY(calc(-1 * var(--radius))) rotate(calc(-1 * var(--angle)));display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid transparent;border-radius:18px;background:transparent;box-sizing:border-box;opacity:.7;text-shadow:0 2px 8px rgba(0,0,0,.9);transition:opacity .12s,background .12s,border-color .12s}
.system-radial-slot span{font-size:14px;font-weight:590;white-space:nowrap}
.system-radial-slot.is-selected{opacity:1;background:rgba(0,0,0,.62);border-color:rgba(255,255,255,.65)}
.system-radial-center{position:absolute;left:50%;top:50%;width:112px;height:112px;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:7px;border-radius:50%;border:1px solid rgba(255,255,255,.22);background:rgba(0,0,0,.5);color:#fff;box-sizing:border-box;text-shadow:0 2px 8px rgba(0,0,0,.75);opacity:.68}
.system-radial-center.is-selected{border-color:rgba(255,255,255,.8);background:rgba(0,0,0,.7);opacity:1}
.system-radial-center>span{font-size:13px;font-weight:580;white-space:nowrap}
@media (prefers-reduced-motion:reduce){.system-radial-slot{transition:none}}
+44
View File
@@ -0,0 +1,44 @@
import {useEffect,useState} from 'react';
import {IconApps,IconCamera,IconDeviceDesktop,IconPlayerRecord,IconRefresh} from '@tabler/icons-react';
import './system-radial-menu.css';
type State={progress:number;open:boolean;direction:string;recording:boolean};
const initial:State={progress:0,open:false,direction:'center',recording:false};
const slots=[
{direction:'up',label:'启动台',icon:IconApps},
{direction:'upRight',label:'',icon:null},
{direction:'right',label:'截图',icon:IconCamera},
{direction:'downRight',label:'',icon:null},
{direction:'down',label:'管理面板',icon:IconDeviceDesktop},
{direction:'downLeft',label:'',icon:null},
{direction:'left',label:'录屏',icon:IconPlayerRecord},
{direction:'upLeft',label:'',icon:null},
];
export function SystemRadialMenu({progressOnly=false}:{progressOnly?:boolean}){
const[state,setState]=useState<State>(initial);
useEffect(()=>{
const update=(event:Event)=>setState((event as CustomEvent<State>).detail);
window.addEventListener('framely.systemMenu',update);
return()=>window.removeEventListener('framely.systemMenu',update);
},[]);
return <main className={'system-radial '+(progressOnly?'is-charging':'is-open')} aria-label="Framely 快捷菜单">
{progressOnly?<div className="system-radial-charge">
<svg className="system-radial-charge-ring" viewBox="0 0 86 86" aria-hidden="true">
<circle className="system-radial-charge-track" cx="43" cy="43" r="31"/>
<circle className="system-radial-charge-progress" cx="43" cy="43" r="31" pathLength="100" strokeDasharray={`${Math.max(0,Math.min(1,state.progress))*100} 100`}/>
</svg>
</div>:<div className="system-radial-face">
{slots.map((slot,index)=>{
const Icon=slot.icon;
if(!Icon)return null;
return <div key={slot.direction} className={'system-radial-slot'+(state.direction===slot.direction?' is-selected':'')} style={{'--angle':`${index*45}deg`} as React.CSSProperties}>
<Icon size={29} stroke={1.7}/><span>{slot.direction==='left'&&state.recording?'结束录屏':slot.direction==='left'?'开始录屏':slot.label}</span>
</div>;
})}
<div className={'system-radial-center'+(state.direction==='center'?' is-selected':'')}>
<IconRefresh size={24} stroke={1.6}/><span>重置视角</span>
</div>
</div>}
</main>;
}