diff --git a/src/steam.rs b/src/steam.rs index 773351a..afce99c 100644 --- a/src/steam.rs +++ b/src/steam.rs @@ -13,12 +13,18 @@ pub struct App { pub remote: bool, #[serde(skip_serializing_if = "Option::is_none")] pub remote_client: Option, + pub remote_targets: Vec, pub id: u32, pub name: String, pub kind: &'static str, pub icon: Option, 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 { 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, remote: Vec) -> 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, remote: Vec) -> 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")); } diff --git a/src/steam/remote.js b/src/steam/remote.js index 2268502..226ca2a 100644 --- a/src/steam/remote.js +++ b/src/steam/remote.js @@ -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); diff --git a/src/steam/remote.rs b/src/steam/remote.rs index 16d102f..a93ff4b 100644 --- a/src/steam/remote.rs +++ b/src/steam/remote.rs @@ -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 { crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js"))) diff --git a/tests/steam_launch_choice_fixture.tsx b/tests/steam_launch_choice_fixture.tsx new file mode 100644 index 0000000..3c2004c --- /dev/null +++ b/tests/steam_launch_choice_fixture.tsx @@ -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(
chosen=client} onClose={()=>closed=true}/>
);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('.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('.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')); diff --git a/tests/test-steam-remote.mjs b/tests/test-steam-remote.mjs new file mode 100644 index 0000000..33a8eae --- /dev/null +++ b/tests/test-steam-remote.mjs @@ -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.'); diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index ac6e7bc..eda20b7 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -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" } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index 4f25626..f359ca2 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -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}", + "架构": "架构" } } diff --git a/ui/src/launcher.css b/ui/src/launcher.css index 008b0f5..4be9f37 100644 --- a/ui/src/launcher.css +++ b/ui/src/launcher.css @@ -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}} diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 5fb3bd4..9ed67f0 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -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 <>

{t('启动方式与菜单')}

void save({prioritizeUi})}/>

{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}

void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}

{t('眼追校准')}

{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}

; } 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(method:string,params:unknown={}):Promise{ @@ -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>('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&&next36){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((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 Mapvoid;reject:(e:Error)=>void;timer:ReturnType;ticket:number}>()); @@ -128,5 +129,5 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori {a.launchUnavailable&&{t(a.launchUnavailable)}}{busyKeys.has(a.key)&&{t('正在执行…')}}})}{panView&¤t+panView.direction>=0&¤t+panView.direction