mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-17 21:01:29 +02:00
List named Remote Play hosts in redesigned localized launch chooser
This commit is contained in:
1 parent
e8c406ffca
commit
6589f85ec6
10 files changed
+112
-20
No files matched your search
+30
-9
@@ -13,12 +13,18 @@ pub struct App {
|
||||
pub remote: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remote_client: Option<String>,
|
||||
pub remote_targets: Vec<RemoteTarget>,
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub icon: Option<String>,
|
||||
pub icon_fit: &'static str,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RemoteTarget {
|
||||
pub client: String,
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
enum Value {
|
||||
Text(String),
|
||||
@@ -213,6 +219,7 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
id,
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
remote_targets: Vec::new(),
|
||||
name: name.into(),
|
||||
kind: if text(v, "installdir").is_some_and(|dir| {
|
||||
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
|
||||
@@ -320,14 +327,7 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
for a in remote {
|
||||
if let Some(existing) = apps.get_mut(&a.id) {
|
||||
// Keep local metadata and a single stable icon, but expose both launch targets.
|
||||
if existing.remote_client.is_none() {
|
||||
existing.remote_client = Some(a.client);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
apps.entry(a.id).or_insert_with(|| {
|
||||
let existing = apps.entry(a.id).or_insert_with(|| {
|
||||
let icon = cached_artwork(&steam, a.id);
|
||||
App {
|
||||
id: a.id,
|
||||
@@ -336,9 +336,23 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
|
||||
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
|
||||
icon: icon.map(|v| v.0),
|
||||
remote: true,
|
||||
remote_client: Some(a.client),
|
||||
remote_client: None,
|
||||
remote_targets: Vec::new(),
|
||||
}
|
||||
});
|
||||
if existing.remote_targets.iter().any(|t| t.client == a.client) {
|
||||
continue;
|
||||
}
|
||||
if existing.remote_client.is_none() {
|
||||
existing.remote_client = Some(a.client.clone());
|
||||
}
|
||||
existing.remote_targets.push(RemoteTarget {
|
||||
client: a.client,
|
||||
name: a.device_name,
|
||||
});
|
||||
existing
|
||||
.remote_targets
|
||||
.sort_by(|a, b| a.name.cmp(&b.name).then(a.client.cmp(&b.client)));
|
||||
}
|
||||
apps.into_values().collect()
|
||||
}
|
||||
@@ -383,22 +397,26 @@ mod tests {
|
||||
icon_fit: "contain",
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
remote_targets: Vec::new(),
|
||||
}];
|
||||
let rows = vec![
|
||||
remote::RemoteApp {
|
||||
id: 42,
|
||||
name: "Same remote".into(),
|
||||
client: "12".into(),
|
||||
device_name: "Gaming PC".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Remote".into(),
|
||||
client: "12".into(),
|
||||
device_name: "Gaming PC".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Other host".into(),
|
||||
client: "13".into(),
|
||||
device_name: "Laptop".into(),
|
||||
},
|
||||
];
|
||||
let apps = merge_remote(home.path(), local, rows);
|
||||
@@ -406,6 +424,9 @@ mod tests {
|
||||
assert!(!apps[0].remote);
|
||||
assert_eq!(apps[0].name, "Local");
|
||||
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
|
||||
assert_eq!(apps[0].remote_targets[0].name, "Gaming PC");
|
||||
assert_eq!(apps[1].remote_targets.len(), 2);
|
||||
assert_eq!(apps[1].remote_targets[1].name, "Laptop");
|
||||
assert!(apps[1].remote);
|
||||
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ async function(request) {
|
||||
if(request.mode==='list')return [];
|
||||
throw Error('Steam remote library is unavailable');
|
||||
}
|
||||
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
|
||||
const connected=new Map(remote.devices.filter(d=>d.status==='Connected').map(d=>[String(d.clientId),String(d.clientName||'').trim().slice(0,256)]));
|
||||
const rows=[];
|
||||
for(const a of store.allApps) {
|
||||
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
|
||||
for(const r of a.remote_per_client_data||[]) {
|
||||
const client=String(r.clientid);
|
||||
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
|
||||
rows.push({id:a.appid,name:String(a.display_name||''),client});
|
||||
rows.push({id:a.appid,name:String(a.display_name||''),client,deviceName:connected.get(client)});
|
||||
}
|
||||
}
|
||||
if(request.mode==='list')return rows.slice(0,10000);
|
||||
|
||||
@@ -11,6 +11,8 @@ pub(super) struct RemoteApp {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub client: String,
|
||||
#[serde(default, rename = "deviceName")]
|
||||
pub device_name: String,
|
||||
}
|
||||
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
|
||||
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {SteamLaunchChoice} from '../ui/src/steam-launch-choice';
|
||||
import {configureLanguage,t} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/launcher.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const wait=()=>new Promise(r=>setTimeout(r,450));
|
||||
let chosen:string|undefined='unset',closed=false;
|
||||
async function show(locale:string){configureLanguage(locale,[]);root.render(<div className="app launcher-shell" style={{height:'100%',background:'#161618'}}><SteamLaunchChoice name="Hades" local targets={[{client:'12',name:'TOORU-PC'},{client:'13',name:'Living Room PC'}]} onChoose={client=>chosen=client} onClose={()=>closed=true}/></div>);await wait();return document.querySelector('.launch-choice')!;}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
let dialog=await show('zh-CN');
|
||||
if(!dialog.textContent?.includes('选择启动方式')||!dialog.textContent.includes('本机启动')||!dialog.textContent.includes('TOORU-PC')||!dialog.textContent.includes('Living Room PC'))throw Error('Missing localized labels or device names');
|
||||
if(dialog.scrollWidth>dialog.clientWidth+1)throw Error('Horizontal overflow');
|
||||
let buttons=dialog.querySelectorAll<HTMLButtonElement>('.launch-choice-target');buttons[2].click();if(chosen!=='13')throw Error('Wrong remote host selected');buttons[0].click();if(chosen!==undefined)throw Error('Local launch still passes remote host');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CHINESE');await wait();
|
||||
dialog=await show('en-US');if(!dialog.textContent?.includes('Choose how to launch'))throw Error('English missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_ENGLISH');await wait();
|
||||
dialog.querySelector<HTMLButtonElement>('.icon-button')!.click();if(!closed)throw Error('Close action broken');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e)}};
|
||||
show('zh-CN').then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
const evaluate=eval(`(${await readFile('src/steam/remote.js','utf8')})`);
|
||||
const launched=[];
|
||||
globalThis.window={RemotePlayStore_SteamUI:{devices:[{clientId:'12',clientName:'Gaming PC',status:'Connected'},{clientId:'13',clientName:'Laptop',status:'Connected'},{clientId:'14',clientName:'Offline',status:'Disconnected'}]},appStore:{allApps:[{appid:42,display_name:'Game',app_type:1,remote_per_client_data:['12','13','14'].map(clientid=>({clientid,installed:true})),BIsPerClientDataLocal:()=>false}]}};
|
||||
globalThis.SteamClient={Apps:{StreamGame:async(...args)=>launched.push(args)}};
|
||||
const rows=await evaluate({mode:'list'});
|
||||
assert.deepEqual(rows.map(r=>[r.client,r.deviceName]),[['12','Gaming PC'],['13','Laptop']]);
|
||||
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),true);
|
||||
assert.deepEqual(launched,[[42,'13',-1]]);
|
||||
window.RemotePlayStore_SteamUI.devices[1].status='Disconnected';
|
||||
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),false);
|
||||
assert.equal(launched.length,1);
|
||||
console.log('Remote device names, selection and disconnected-host validation passed.');
|
||||
@@ -974,6 +974,9 @@
|
||||
"Android user did not unlock; export logs and retry.": "Android user did not unlock; export logs and retry.",
|
||||
"Android external storage did not mount; export logs and retry.": "Android external storage did not mount; export logs and retry.",
|
||||
"Android container stopped before becoming ready; export logs and retry.": "Android container stopped before becoming ready; export logs and retry.",
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry."
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry.",
|
||||
"在此 Frame 上运行": "Run on this Frame",
|
||||
"远程设备 {0}": "Remote device {0}",
|
||||
"架构": "Architecture"
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -964,6 +964,19 @@
|
||||
"Android user did not unlock; export logs and retry.": "Android 用户未解锁,请导出日志后重试。",
|
||||
"Android external storage did not mount; export logs and retry.": "Android 外部存储未挂载,请导出日志后重试。",
|
||||
"Android container stopped before becoming ready; export logs and retry.": "Android 容器在就绪前退出,请导出日志后重试。",
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。"
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。",
|
||||
"选择启动方式": "选择启动方式",
|
||||
"远程畅玩": "远程畅玩",
|
||||
"保存 Steam 入口设置": "保存 Steam 入口设置",
|
||||
"保留安装的 APK 到 Steam": "保留安装的 APK 到 Steam",
|
||||
"Steam UI operation failed": "Steam 界面操作失败,请保持 Steam 唤醒后重试。",
|
||||
"Remote game or host is no longer available": "远程游戏或设备已不可用,请检查电脑连接后重试。",
|
||||
"本机启动": "本机启动",
|
||||
"通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。": "通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。",
|
||||
"空容器": "空容器",
|
||||
"默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。": "默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。",
|
||||
"在此 Frame 上运行": "在此 Frame 上运行",
|
||||
"远程设备 {0}": "远程设备 {0}",
|
||||
"架构": "架构"
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -199,4 +199,7 @@
|
||||
.launch-remote-mark{position:absolute;right:18px;bottom:11px;width:29px;height:29px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#252525e8;color:#f0f0f0;box-shadow:0 2px 6px #0005;pointer-events:none;z-index:2}
|
||||
@media(max-width:900px){.launch-remote-mark{right:1px;bottom:1px}}
|
||||
|
||||
.launch-choice-actions{display:flex;gap:14px;margin:24px 0}.launch-choice-actions button{flex:1;display:flex;align-items:center;justify-content:center;gap:12px;min-height:64px}
|
||||
.launcher-shell .launch-choice{width:600px;padding:28px;border:1px solid #ffffff20;background:rgba(24,24,26,.96);box-shadow:inset 0 1px 0 #ffffff0c,0 24px 72px #00000055;max-height:90%;overflow:auto}
|
||||
.launch-choice-heading{display:flex;align-items:center;gap:16px;margin-bottom:26px}.launch-choice-heading>div{flex:1;min-width:0}.launch-choice-heading h2{margin:0 0 4px;font-size:25px;overflow-wrap:anywhere}.launch-choice-heading p{margin:0;color:var(--muted);font-size:18px}.launch-choice-heading .icon-button{align-self:flex-start;min-width:40px;min-height:40px;padding:8px}
|
||||
.launch-choice-targets{display:flex;flex-direction:column;gap:10px}.launch-choice-target{width:100%;min-height:86px;padding:16px 18px;text-align:left;gap:18px;border:1px solid #ffffff12;border-radius:13px;background:#ffffff05;transition:background 140ms,border-color 140ms}.launch-choice-target:hover:not(:disabled){background:#ffffff10;border-color:#ffffff28}.launch-choice-symbol{display:flex;align-items:center;justify-content:center;width:44px;height:44px;flex:none;color:#d6d9de}.launch-choice-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.launch-choice-copy b{font-size:21px;font-weight:550;overflow-wrap:anywhere}.launch-choice-copy small{font-size:16px}.launch-choice-arrow{color:#b7bbc2}
|
||||
@media(prefers-reduced-motion:reduce){.launch-choice-target{transition:none}}
|
||||
+7
-6
@@ -1,3 +1,4 @@
|
||||
import {SteamLaunchChoice} from './steam-launch-choice';
|
||||
import {AppPlaceholder} from './app-placeholder';
|
||||
import {SwitchRow} from './switch';
|
||||
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
@@ -13,7 +14,7 @@ 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']};
|
||||
type SteamApp={remote?:boolean;remoteClient?:string;id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
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)];
|
||||
return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4})));
|
||||
@@ -32,7 +33,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
|
||||
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></>;
|
||||
}
|
||||
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
|
||||
type LaunchItem={remote?:boolean;remoteClient?:string;key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;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};
|
||||
const emptyOrder:string[]=[];
|
||||
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
|
||||
async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T>{
|
||||
@@ -70,7 +71,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
// The native compositor owns surface fades; animating this high-resolution
|
||||
// page too would multiply alpha and force a full CEF readback every frame.
|
||||
useEffect(()=>{const open=()=>{if(shell.current)shell.current.style.pointerEvents='';};const closing=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}if(shell.current)shell.current.style.pointerEvents='none';};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',closing);};},[]);
|
||||
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
|
||||
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,remoteTargets:a.remoteTargets,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
|
||||
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
|
||||
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
|
||||
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void launcherRequest<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
|
||||
@@ -107,8 +108,8 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
|
||||
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
|
||||
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
|
||||
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&!a.remote&&a.remoteClient){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
|
||||
function chooseLaunch(remote:boolean){if(!launchChoice)return;const item={...launchChoice,remoteClient:remote?launchChoice.remoteClient:undefined};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
|
||||
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&a.remoteClient&&(!a.remote||(a.remoteTargets?.length??0)>1)){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
|
||||
function chooseLaunch(client?:string){if(!launchChoice)return;const item={...launchChoice,remoteClient:client};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
|
||||
function launch(a:LaunchItem){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id,remoteClient:a.remoteClient,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
|
||||
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
|
||||
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
|
||||
@@ -128,5 +129,5 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark"><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}</span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
|
||||
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>setPage(n)}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
|
||||
{launchChoice&&<div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()}><div className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title"><h2 id="launch-choice-title">{launchChoice.name}</h2><p>{t('选择启动方式')}</p><div className="launch-choice-actions"><button onClick={()=>chooseLaunch(false)}><IconDeviceDesktop size={24}/>{t('本机启动')}</button><button onClick={()=>chooseLaunch(true)}><IconDeviceDesktopShare size={24}/>{t('远程畅玩')}</button></div><button onClick={()=>setLaunchChoice(null)}>{t('取消')}</button></div></div>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
|
||||
{launchChoice&&<SteamLaunchChoice name={launchChoice.name} icon={launchChoice.icon} local={!launchChoice.remote} targets={launchChoice.remoteTargets?.length?launchChoice.remoteTargets:[{client:launchChoice.remoteClient!,name:''}]} onChoose={chooseLaunch} onClose={()=>setLaunchChoice(null)}/>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React,{useEffect,useRef} from 'react';
|
||||
import {IconArrowRight,IconDeviceDesktop,IconDeviceDesktopShare,IconX} from '@tabler/icons-react';
|
||||
import {PluginImage} from './store';
|
||||
import {t} from './i18n';
|
||||
export type RemoteLaunchTarget={client:string;name:string};
|
||||
export function SteamLaunchChoice({name,icon,local,targets,onChoose,onClose}:{name:string;icon?:string|null;local:boolean;targets:RemoteLaunchTarget[];onChoose:(client?:string)=>void;onClose:()=>void}){
|
||||
const dialog=useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{const previous=document.activeElement as HTMLElement|null;dialog.current?.querySelector<HTMLButtonElement>('.launch-choice-target')?.focus();return()=>previous?.focus();},[]);
|
||||
return <div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()} onClick={e=>e.stopPropagation()}><div ref={dialog} className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title" onKeyDown={e=>{if(e.key!=='Tab')return;const buttons=Array.from(dialog.current?.querySelectorAll<HTMLButtonElement>('button')??[]);const first=buttons[0],last=buttons.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}}>
|
||||
<header className="launch-choice-heading"><PluginImage kind="steam" src={icon} name={name} size={56}/><div><h2 id="launch-choice-title">{name}</h2><p>{t('选择启动方式')}</p></div><button className="icon-button" aria-label={t('取消')} onClick={onClose}><IconX size={22}/></button></header>
|
||||
<div className="launch-choice-targets">{local&&<button className="launch-choice-target" onClick={()=>onChoose()}><span className="launch-choice-symbol"><IconDeviceDesktop size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{t('本机启动')}</b><small>{t('在此 Frame 上运行')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>}{targets.map(target=><button key={target.client} className="launch-choice-target" onClick={()=>onChoose(target.client)}><span className="launch-choice-symbol"><IconDeviceDesktopShare size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{target.name||t('远程设备 {0}',{0:target.client})}</b><small>{t('远程畅玩')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>)}</div>
|
||||
</div></div>;
|
||||
}
|
||||
Reference in new issue
Block a user