优化局域网遥控

This commit is contained in:
mtvpls
2026-06-19 15:59:44 +08:00
parent 9881ad3238
commit e9ac8f3067
5 changed files with 27 additions and 80 deletions
@@ -11,7 +11,6 @@ import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.mozilla.geckoview.GeckoRuntime;
import org.mozilla.geckoview.GeckoSession;
@@ -26,7 +25,6 @@ public class MainActivity extends Activity implements RemoteCommandHandler {
private GeckoView geckoView;
private boolean canGoBack = false;
private LocalRemoteServer localRemoteServer;
private TextView remoteHintView;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@Override
@@ -73,21 +71,6 @@ public class MainActivity extends Activity implements RemoteCommandHandler {
private void setupLocalRemoteServer() {
localRemoteServer = new LocalRemoteServer(this);
localRemoteServer.start();
String url = localRemoteServer.getRemoteUrl();
if (url == null) return;
remoteHintView = new TextView(this);
remoteHintView.setText("局域网遥控:手机浏览器打开\n" + url);
remoteHintView.setTextColor(0xFFE0E7FF);
remoteHintView.setTextSize(13);
remoteHintView.setPadding(18, 12, 18, 12);
remoteHintView.setBackgroundColor(0xAA111827);
addContentView(remoteHintView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
mainHandler.postDelayed(() -> {
if (remoteHintView != null) remoteHintView.setVisibility(View.GONE);
}, 15000);
}
private int keyCodeForRemoteKey(String key, String digit) {
@@ -241,9 +241,11 @@ public class LocalRemoteServer {
private String remoteHtml() {
return "<!doctype html><html lang='zh-CN'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no'>" +
"<title>MoonTVPlus 局域网遥控器</title><style>" +
":root{color-scheme:dark;--bg:#070816;--panel:rgba(255,255,255,.09);--panel2:rgba(255,255,255,.14);--line:rgba(255,255,255,.16);--text:#f8fafc;--muted:#a5b4fc;--primary:#6366f1;--ok:#10b981;--danger:#fb7185}*{box-sizing:border-box}body{margin:0;min-height:100svh;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:radial-gradient(circle at 20% 0%,#312e81 0,#111827 34%,#070816 100%);color:var(--text);display:flex;align-items:center;justify-content:center;padding:18px}.shell{width:min(430px,100%)}.hero{margin-bottom:16px}.pill{display:inline-flex;gap:8px;align-items:center;border:1px solid var(--line);background:rgba(99,102,241,.16);color:#c7d2fe;border-radius:999px;padding:7px 11px;font-size:12px;font-weight:800}.dot{width:8px;height:8px;border-radius:999px;background:var(--ok);box-shadow:0 0 18px var(--ok)}h1{font-size:clamp(32px,10vw,54px);line-height:.9;margin:14px 0 8px;letter-spacing:-.07em;font-weight:950}.sub{margin:0;color:#cbd5e1;font-size:14px;line-height:1.6}.card{border:1px solid var(--line);background:linear-gradient(180deg,rgba(255,255,255,.13),rgba(255,255,255,.06));box-shadow:0 24px 90px rgba(0,0,0,.42);backdrop-filter:blur(18px);border-radius:30px;padding:18px}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.btn{min-height:66px;min-width:66px;border:1px solid var(--line);border-radius:22px;background:var(--panel);color:var(--text);font-size:22px;font-weight:900;cursor:pointer;touch-action:none;transition:background .18s,border-color .18s,transform .12s,box-shadow .18s}.btn:active{transform:translateY(1px);background:var(--panel2)}.btn:focus-visible{outline:3px solid #818cf8;outline-offset:2px}.ok{background:linear-gradient(135deg,#10b981,#34d399);color:#04130d;box-shadow:0 12px 32px rgba(16,185,129,.26)}.wide{grid-column:span 3}.row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:12px}.small{min-height:54px;font-size:15px;border-radius:18px}.input{display:flex;gap:10px;margin-top:14px}.input input{min-height:50px;min-width:0;flex:1;border:1px solid var(--line);border-radius:18px;background:rgba(255,255,255,.08);color:var(--text);padding:0 14px;font-size:16px;outline:none}.input input:focus{border-color:#818cf8}.send{min-height:50px;border-radius:18px;border:0;background:#6366f1;color:white;font-weight:900;padding:0 16px;cursor:pointer}.status{margin-top:12px;color:#a5b4fc;font-size:12px;text-align:center}@media (prefers-reduced-motion:reduce){*{transition:none!important}}" +
"</style></head><body><main class='shell'><section class='hero'><div class='pill'><span class='dot'></span><span>LAN REMOTE</span></div><h1>电视遥控器</h1><p class='sub'>同一局域网直连 MoonTVPlus TV,低延迟控制方向、播放和文本输入。</p></section><section class='card' aria-label='遥控器按键'><div class='grid'><span></span><button class='btn' data-key='up' aria-label=''></button><span></span><button class='btn' data-key='left' aria-label=''></button><button class='btn ok' data-key='ok' aria-label='确定'>OK</button><button class='btn' data-key='right' aria-label='右'>→</button><span></span><button class='btn' data-key='down' aria-label=''></button><span></span></div><div class='row'><button class='btn small' data-key='back'>返回</button><button class='btn small' data-key='home'>首页</button><button class='btn small' data-key='menu'>菜单</button></div><div class='row'><button class='btn small' data-key='pageUp'>上一页</button><button class='btn small' data-key='playPause'>播放/暂停</button><button class='btn small' data-key='pageDown'>下一页</button></div><div class='input'><input id='text' placeholder='输入文字发送到电视' autocomplete='off'><button class='send' id='send'>发送</button></div><div class='status' id='status'>正在连接电视...</div></section></main><script>" +
"const token=new URLSearchParams(location.search).get('token')||'';const statusEl=document.getElementById('status');let ws;function setStatus(t){statusEl.textContent=t}function connect(){ws=new WebSocket('ws://'+location.host+'/ws?token='+encodeURIComponent(token));ws.onopen=()=>setStatus('已连接');ws.onclose=()=>{setStatus('连接断开,正在重连...');setTimeout(connect,1200)};ws.onerror=()=>setStatus('连接异常,请确认电视与手机在同一局域网')}function send(o){if(ws&&ws.readyState===1)ws.send(JSON.stringify(o))}connect();function key(k,repeat=false){send({version:1,type:'key',command:{key:k,repeat}})}document.querySelectorAll('[data-key]').forEach(btn=>{let timer=null,loop=null;const k=btn.dataset.key;const stop=()=>{clearTimeout(timer);clearInterval(loop);timer=loop=null};btn.addEventListener('pointerdown',e=>{e.preventDefault();key(k,false);timer=setTimeout(()=>{loop=setInterval(()=>key(k,true),135)},380)});['pointerup','pointercancel','pointerleave'].forEach(n=>btn.addEventListener(n,stop));});document.getElementById('send').onclick=()=>{const input=document.getElementById('text');send({version:1,type:'text',command:{mode:'replace',text:input.value}});setStatus('文本已发送')};document.getElementById('text').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('send').click()});" +
":root{color-scheme:dark;--bg:#070816;--panel:rgba(255,255,255,.09);--panel2:rgba(255,255,255,.14);--line:rgba(255,255,255,.16);--text:#f8fafc;--muted:#a5b4fc;--primary:#6366f1;--ok:#fff;--danger:#fb7185}*{box-sizing:border-box}body{margin:0;min-height:100svh;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:radial-gradient(circle at 20% 0%,#312e81 0,#111827 34%,#070816 100%);color:var(--text);display:flex;align-items:center;justify-content:center;padding:18px}.shell{width:min(430px,100%)}.hero{margin-bottom:14px}.pill{display:inline-flex;gap:8px;align-items:center;border:1px solid var(--line);background:rgba(99,102,241,.16);color:#c7d2fe;border-radius:999px;padding:7px 11px;font-size:12px;font-weight:900}.dot{width:8px;height:8px;border-radius:999px;background:#10b981;box-shadow:0 0 18px #10b981}h1{font-size:clamp(30px,9vw,50px);line-height:.94;margin:14px 0 8px;letter-spacing:-.06em;font-weight:950}.sub{margin:0;color:#cbd5e1;font-size:14px;line-height:1.55}.card{border:1px solid var(--line);background:linear-gradient(180deg,rgba(255,255,255,.13),rgba(255,255,255,.06));box-shadow:0 24px 90px rgba(0,0,0,.42);backdrop-filter:blur(18px);border-radius:30px;padding:18px}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.digits{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:14px}.btn{min-height:62px;min-width:62px;border:1px solid var(--line);border-radius:22px;background:var(--panel);color:var(--text);font-size:22px;font-weight:950;cursor:pointer;touch-action:none;transition:background .18s,border-color .18s,transform .12s,box-shadow .18s}.btn:active{transform:translateY(1px);background:var(--panel2)}.btn:focus-visible,.send:focus-visible,.textbtn:focus-visible{outline:3px solid #818cf8;outline-offset:2px}.small{min-height:54px;border-radius:18px;font-size:15px}.digit{min-height:44px;border-radius:14px;font-size:18px}.ok{border-radius:999px;background:#f8fafc;color:#0f172a;box-shadow:0 14px 34px rgba(255,255,255,.14)}.spacer{min-height:1px}.textpanel{margin-top:14px;border:1px solid var(--line);border-radius:22px;background:rgba(0,0,0,.18);padding:12px}.label{margin-bottom:8px;color:#cbd5e1;font-size:13px;font-weight:900}.input{width:100%;min-height:82px;resize:none;border:1px solid var(--line);border-radius:16px;background:rgba(255,255,255,.08);color:var(--text);padding:11px 12px;font-size:15px;outline:none}.input:focus{border-color:#818cf8}.actions{display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;margin-top:8px}.send,.textbtn{min-height:42px;border-radius:14px;border:1px solid var(--line);font-weight:950;cursor:pointer}.send{background:#e11d48;color:#fff;border-color:#fb7185}.textbtn{background:rgba(255,255,255,.08);color:#e2e8f0}.status{margin-top:12px;color:#a5b4fc;font-size:12px;text-align:center}@media (prefers-reduced-motion:reduce){*{transition:none!important}}" +
"</style></head><body><main class='shell'><section class='hero'><div class='pill'><span class='dot'></span><span>LAN REMOTE</span></div><h1>电视遥控器</h1><p class='sub'>手机与电视在同一局域网时,可直接控制 MoonTVPlus TV。</p></section><section class='card' aria-label='遥控器按键'><div class='grid'><button class='btn small' data-key='back' aria-label='返回'></button><button class='btn small' data-key='home' aria-label='主页'></button><button class='btn small' data-key='menu' aria-label='菜单'>☰</button><span class='spacer'></span><button class='btn' data-key='up' aria-label=''></button><span class='spacer'></span><button class='btn' data-key='left' aria-label='左'>←</button><button class='btn ok' data-key='ok' aria-label='确认'>↵</button><button class='btn' data-key='right' aria-label='右'>→</button><span class='spacer'></span><button class='btn' data-key='down' aria-label='下'>↓</button><span class='spacer'></span></div><div class='digits'>" +
"<button class='btn digit' data-digit='1'>1</button><button class='btn digit' data-digit='2'>2</button><button class='btn digit' data-digit='3'>3</button><button class='btn digit' data-digit='4'>4</button><button class='btn digit' data-digit='5'>5</button><button class='btn digit' data-digit='6'>6</button><button class='btn digit' data-digit='7'>7</button><button class='btn digit' data-digit='8'>8</button><button class='btn digit' data-digit='9'>9</button><button class='btn digit' data-digit='0'>0</button></div><div class='textpanel'><div class='label'>文本输入</div><textarea class='input' id='text' rows='3' placeholder='输入后发送到电视端当前输入框'></textarea><div class='actions'><button class='send' id='send'>发送</button><button class='textbtn' id='backspace'>退格</button><button class='textbtn' id='clear'>清空</button></div></div><div class='status' id='status'>正在连接电视...</div></section></main><script>" +
"const token=new URLSearchParams(location.search).get('token')||'';const statusEl=document.getElementById('status');let ws;function setStatus(t){statusEl.textContent=t}function connect(){ws=new WebSocket('ws://'+location.host+'/ws?token='+encodeURIComponent(token));ws.onopen=()=>setStatus('已连接');ws.onclose=()=>{setStatus('连接断开,正在重连...');setTimeout(connect,1200)};ws.onerror=()=>setStatus('连接异常,请确认电视与手机在同一局域网')}function send(o){if(ws&&ws.readyState===1)ws.send(JSON.stringify(o))}connect();function key(k,repeat=false,digit){send({version:1,type:'key',command:{key:k,repeat,digit}})}document.querySelectorAll('[data-key]').forEach(btn=>{let timer=null,loop=null;const k=btn.dataset.key;const repeatable=['up','down','left','right'].includes(k);const stop=()=>{clearTimeout(timer);clearInterval(loop);timer=loop=null};btn.addEventListener('pointerdown',e=>{e.preventDefault();key(k,false);if(repeatable){timer=setTimeout(()=>{loop=setInterval(()=>key(k,true),135)},380)}});['pointerup','pointercancel','pointerleave'].forEach(n=>btn.addEventListener(n,stop));});document.querySelectorAll('[data-digit]').forEach(btn=>btn.addEventListener('click',()=>key('digit',false,btn.dataset.digit)));document.getElementById('send').onclick=()=>{const input=document.getElementById('text');send({version:1,type:'text',command:{mode:'replace',text:input.value}});setStatus('文本已发送')};document.getElementById('backspace').onclick=()=>send({version:1,type:'text',command:{mode:'backspace',text:''}});document.getElementById('clear').onclick=()=>{document.getElementById('text').value='';send({version:1,type:'text',command:{mode:'clear',text:''}})};document.getElementById('text').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey))document.getElementById('send').click()});" +
"</script></body></html>";
}
}
@@ -21,7 +21,6 @@ import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.TextView;
import java.net.URLEncoder;
@@ -31,7 +30,6 @@ public class MainActivity extends Activity implements RemoteCommandHandler {
private View customView;
private WebChromeClient.CustomViewCallback customViewCallback;
private LocalRemoteServer localRemoteServer;
private TextView remoteHintView;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@Override
@@ -148,10 +146,6 @@ public class MainActivity extends Activity implements RemoteCommandHandler {
return localRemoteServer == null ? -1 : localRemoteServer.getPort();
}
@JavascriptInterface
public void showHint() {
mainHandler.post(() -> showLocalRemoteHint(true));
}
}
private void injectLocalRemoteInfo() {
@@ -164,38 +158,9 @@ public class MainActivity extends Activity implements RemoteCommandHandler {
webView.evaluateJavascript(script, null);
}
private void showLocalRemoteHint(boolean persistent) {
String url = localRemoteServer == null ? null : localRemoteServer.getRemoteUrl();
if (url == null) return;
if (remoteHintView == null) {
remoteHintView = new TextView(this);
remoteHintView.setTextColor(0xFFE0E7FF);
remoteHintView.setTextSize(13);
remoteHintView.setPadding(18, 12, 18, 12);
remoteHintView.setBackgroundColor(0xAA111827);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.leftMargin = 24;
params.topMargin = 24;
root.addView(remoteHintView, params);
}
remoteHintView.setText("局域网遥控:手机浏览器打开\n" + url);
remoteHintView.setVisibility(View.VISIBLE);
if (!persistent) {
mainHandler.postDelayed(() -> {
if (remoteHintView != null) {
remoteHintView.setVisibility(View.GONE);
}
}, 15000);
}
}
private void setupLocalRemoteServer() {
localRemoteServer = new LocalRemoteServer(this);
localRemoteServer.start();
showLocalRemoteHint(false);
}
private int keyCodeForRemoteKey(String key, String digit) {
+2 -18
View File
@@ -37,7 +37,6 @@ const LOCAL_REMOTE_URL_KEY = 'moontv_local_remote_url';
type MoonTVLocalRemoteBridge = {
getRemoteUrl?: () => string;
showHint?: () => void;
};
declare global {
@@ -89,7 +88,6 @@ export default function TVMePage() {
const [loggingOut, setLoggingOut] = useState(false);
const [error, setError] = useState('');
const [localRemoteUrl, setLocalRemoteUrl] = useState('');
const [nativeHintAvailable, setNativeHintAvailable] = useState(false);
const [copyStatus, setCopyStatus] = useState('');
const [upDownAction, setUpDownAction] = useState<TVPlayerUpDownAction>(
DEFAULT_TV_PLAYER_UP_DOWN_ACTION
@@ -107,7 +105,6 @@ export default function TVMePage() {
useEffect(() => {
const readLocalRemoteUrl = () => {
const bridgeUrl = window.MoonTVLocalRemote?.getRemoteUrl?.() || '';
setNativeHintAvailable(Boolean(window.MoonTVLocalRemote?.showHint));
setLocalRemoteUrl(
bridgeUrl ||
window.__MOONTV_LOCAL_REMOTE_URL ||
@@ -184,9 +181,6 @@ export default function TVMePage() {
};
const showLocalRemoteHint = () => {
window.MoonTVLocalRemote?.showHint?.();
};
const copyLocalRemoteUrl = async () => {
if (!localRemoteUrl) return;
@@ -310,7 +304,7 @@ export default function TVMePage() {
</h2>
<p className='mt-4 text-xl leading-relaxed text-slate-300'>
WiFi
WiFi
</p>
{localRemoteUrl ? (
@@ -320,16 +314,6 @@ export default function TVMePage() {
{localRemoteUrl}
</div>
<div className='mt-5 flex flex-wrap gap-3'>
{nativeHintAvailable && (
<button
type='button'
onClick={showLocalRemoteHint}
className='tv-focusable inline-flex cursor-pointer items-center justify-center gap-2 rounded-2xl bg-indigo-600 px-5 py-4 text-lg font-black text-white outline-none transition hover:bg-indigo-500 focus:ring-4 focus:ring-indigo-300'
>
<QrCode className='h-6 w-6' />
</button>
)}
<button
type='button'
onClick={copyLocalRemoteUrl}
@@ -347,7 +331,7 @@ export default function TVMePage() {
</div>
) : (
<div className='mt-6 rounded-3xl border border-amber-300/20 bg-amber-400/10 p-5 text-lg leading-relaxed text-amber-100'>
APK 使 APK
APK 使 APK
</div>
)}
</div>
+20 -7
View File
@@ -1372,8 +1372,21 @@ export const UserMenu: React.FC = () => {
const handleQrLoginResult = useCallback((rawValue: string) => {
try {
const url = new URL(rawValue, window.location.origin);
const isLocalRemoteUrl =
(url.protocol === 'http:' || url.protocol === 'https:') &&
url.searchParams.has('token') &&
(url.pathname === '/remote' || url.pathname.endsWith('/remote'));
if (isLocalRemoteUrl) {
setTvQrScannerStatus('识别成功,正在打开局域网遥控器...');
stopTvQrScanner();
window.location.href = url.href;
return true;
}
if (url.origin !== window.location.origin || url.pathname !== '/qr-login') {
setTvQrScannerError('未识别到本站电视登录二维码,请扫描电视屏幕上的二维码。');
setTvQrScannerError('未识别到电视登录二维码或局域网遥控二维码,请扫描电视屏幕上的二维码。');
return false;
}
@@ -1388,7 +1401,7 @@ export const UserMenu: React.FC = () => {
window.location.href = `/qr-login?token=${encodeURIComponent(token)}`;
return true;
} catch {
setTvQrScannerError('二维码内容无效,请扫描电视端显示的登录二维码。');
setTvQrScannerError('二维码内容无效,请扫描电视端显示的登录二维码或局域网遥控二维码。');
return false;
}
}, [stopTvQrScanner]);
@@ -1424,7 +1437,7 @@ export const UserMenu: React.FC = () => {
await tvQrVideoRef.current.play();
const detector = new BarcodeDetectorCtor({ formats: ['qr_code'] });
setTvQrScannerStatus('请将电视屏幕上的登录二维码放入取景框');
setTvQrScannerStatus('请将电视屏幕上的登录二维码或局域网遥控二维码放入取景框');
const scan = async () => {
if (tvQrScanStopRef.current || !tvQrVideoRef.current) return;
@@ -4408,8 +4421,8 @@ export const UserMenu: React.FC = () => {
<Smartphone className='h-4 w-4' />
</div>
<h3 className='mt-3 text-2xl font-black'></h3>
<p className='mt-1 text-sm text-white/75'></p>
<h3 className='mt-3 text-2xl font-black'></h3>
<p className='mt-1 text-sm text-white/75'></p>
</div>
<button
type='button'
@@ -4670,7 +4683,7 @@ export const UserMenu: React.FC = () => {
</div>
<p className='mt-5 text-sm leading-6 text-slate-600 dark:text-slate-400'>
{tvModeEnabled
? '电视端打开 /tv 后会显示二维码;手机在这里打开相机扫描并确认登录。'
? '电视端打开 /tv 后可扫码登录;在电视端“我的”页也可扫描局域网遥控二维码。'
: '当前部署未开启 TV 模式,/tv 页面和 Web 电视遥控不可用。'}
</p>
{!tvModeEnabled && (
@@ -4685,7 +4698,7 @@ export const UserMenu: React.FC = () => {
disabled={!tvModeEnabled}
className='inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-rose-600 px-4 py-3 text-sm font-black text-white transition hover:bg-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/70 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 dark:disabled:bg-white/10 dark:disabled:text-slate-500'
>
<Smartphone className='h-4 w-4' />
</button>
<button