From 9881ad32384e20f16b55a0b543e917c2fdf71851 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 19 Jun 2026 15:26:18 +0800 Subject: [PATCH] =?UTF-8?q?web=20tv=E5=A2=9E=E5=8A=A0=E5=B1=80=E5=9F=9F?= =?UTF-8?q?=E7=BD=91=E9=81=A5=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moontvplus/tv/MainActivity.java | 123 ++++++++- .../app/src/main/AndroidManifest.xml | 1 + .../com/moontvplus/tv/LocalRemoteServer.java | 249 ++++++++++++++++++ .../moontvplus/tv/RemoteCommandHandler.java | 6 + .../java/com/moontvplus/tv/MainActivity.java | 151 ++++++++++- src/app/tv/me/page.tsx | 133 ++++++++++ src/components/tv/TVRemoteReceiver.tsx | 38 ++- 7 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/com/moontvplus/tv/LocalRemoteServer.java create mode 100644 apps/android-tv/app/src/main/java/com/moontvplus/tv/RemoteCommandHandler.java diff --git a/apps/android-tv/app/src/gecko/java/com/moontvplus/tv/MainActivity.java b/apps/android-tv/app/src/gecko/java/com/moontvplus/tv/MainActivity.java index 4ccd6b3..79202f5 100644 --- a/apps/android-tv/app/src/gecko/java/com/moontvplus/tv/MainActivity.java +++ b/apps/android-tv/app/src/gecko/java/com/moontvplus/tv/MainActivity.java @@ -2,22 +2,32 @@ package com.moontvplus.tv; import android.app.Activity; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; import android.view.View; 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; import org.mozilla.geckoview.GeckoView; -public class MainActivity extends Activity { +import java.net.URLEncoder; + +public class MainActivity extends Activity implements RemoteCommandHandler { private static GeckoRuntime runtime; private GeckoSession session; private GeckoView geckoView; private boolean canGoBack = false; + private LocalRemoteServer localRemoteServer; + private TextView remoteHintView; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); @Override protected void onCreate(Bundle savedInstanceState) { @@ -55,7 +65,112 @@ public class MainActivity extends Activity { }); session.open(runtime); geckoView.setSession(session); - session.loadUri(buildTvUrl(BuildConfig.BASE_URL)); + setupLocalRemoteServer(); + session.loadUri(withLocalRemoteHash(buildTvUrl(BuildConfig.BASE_URL))); + } + + + 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) { + if ("up".equals(key)) return KeyEvent.KEYCODE_DPAD_UP; + if ("down".equals(key)) return KeyEvent.KEYCODE_DPAD_DOWN; + if ("left".equals(key)) return KeyEvent.KEYCODE_DPAD_LEFT; + if ("right".equals(key)) return KeyEvent.KEYCODE_DPAD_RIGHT; + if ("ok".equals(key)) return KeyEvent.KEYCODE_DPAD_CENTER; + if ("back".equals(key)) return KeyEvent.KEYCODE_BACK; + if ("menu".equals(key)) return KeyEvent.KEYCODE_MENU; + if ("home".equals(key)) return KeyEvent.KEYCODE_HOME; + if ("playPause".equals(key)) return KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; + if ("pageUp".equals(key)) return KeyEvent.KEYCODE_PAGE_UP; + if ("pageDown".equals(key)) return KeyEvent.KEYCODE_PAGE_DOWN; + if ("digit".equals(key) && digit != null && digit.length() == 1 && digit.charAt(0) >= '0' && digit.charAt(0) <= '9') { + return KeyEvent.KEYCODE_0 + (digit.charAt(0) - '0'); + } + return KeyEvent.KEYCODE_UNKNOWN; + } + + @Override + public void onRemoteKey(String key, boolean repeat, String digit) { + int keyCode = keyCodeForRemoteKey(key, digit); + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) return; + mainHandler.post(() -> { + if (keyCode == KeyEvent.KEYCODE_BACK) { + onBackPressed(); + return; + } + dispatchKeyCode(keyCode, repeat); + }); + } + + @Override + public void onRemoteText(String mode, String text) { + mainHandler.post(() -> { + String safeMode = mode == null ? "replace" : mode; + if ("clear".equals(safeMode) || "replace".equals(safeMode)) { + dispatchCtrlA(); + dispatchKeyCode(KeyEvent.KEYCODE_DEL, false); + } else if ("backspace".equals(safeMode)) { + dispatchKeyCode(KeyEvent.KEYCODE_DEL, false); + return; + } + + if (!"clear".equals(safeMode) && text != null && !text.isEmpty()) { + dispatchText(text); + } + }); + } + + private void dispatchKeyCode(int keyCode, boolean repeat) { + long now = System.currentTimeMillis(); + geckoView.dispatchKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, repeat ? 1 : 0)); + geckoView.dispatchKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0)); + } + + private void dispatchCtrlA() { + long now = System.currentTimeMillis(); + geckoView.dispatchKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A, 0, KeyEvent.META_CTRL_ON)); + geckoView.dispatchKeyEvent(new KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_A, 0, KeyEvent.META_CTRL_ON)); + } + + private void dispatchText(String text) { + KeyEvent[] events = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD).getEvents(text.toCharArray()); + if (events != null) { + for (KeyEvent event : events) { + geckoView.dispatchKeyEvent(event); + } + return; + } + + geckoView.dispatchKeyEvent(new KeyEvent(System.currentTimeMillis(), text, KeyCharacterMap.VIRTUAL_KEYBOARD, 0)); + } + + private String withLocalRemoteHash(String url) { + String remoteUrl = localRemoteServer == null ? null : localRemoteServer.getRemoteUrl(); + if (remoteUrl == null || remoteUrl.isEmpty()) return url; + try { + return url + "#localRemoteUrl=" + URLEncoder.encode(remoteUrl, "UTF-8"); + } catch (Exception ignored) { + return url; + } } private static String buildTvUrl(String baseUrl) { @@ -86,6 +201,10 @@ public class MainActivity extends Activity { @Override protected void onDestroy() { + if (localRemoteServer != null) { + localRemoteServer.stop(); + localRemoteServer = null; + } if (session != null) { session.close(); session = null; diff --git a/apps/android-tv/app/src/main/AndroidManifest.xml b/apps/android-tv/app/src/main/AndroidManifest.xml index 74e1431..c99c420 100644 --- a/apps/android-tv/app/src/main/AndroidManifest.xml +++ b/apps/android-tv/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + = 2) path = parts[1]; + + String line; + String websocketKey = null; + boolean websocket = false; + while ((line = reader.readLine()) != null && line.length() > 0) { + String lower = line.toLowerCase(Locale.ROOT); + if (lower.startsWith("sec-websocket-key:")) websocketKey = line.substring(line.indexOf(':') + 1).trim(); + if (lower.startsWith("upgrade:") && lower.contains("websocket")) websocket = true; + } + + if (websocket) { + if (!isAuthorized(path) || websocketKey == null) { + writeText(s.getOutputStream(), 403, "text/plain; charset=utf-8", "Forbidden"); + return; + } + handleWebSocket(s, websocketKey); + return; + } + + if (path.startsWith("/health")) { + writeText(s.getOutputStream(), 200, "application/json; charset=utf-8", "{\"ok\":true}"); + } else if (path.startsWith("/remote") || path.equals("/") || path.startsWith("/?")) { + writeText(s.getOutputStream(), 200, "text/html; charset=utf-8", remoteHtml()); + } else { + writeText(s.getOutputStream(), 404, "text/plain; charset=utf-8", "Not found"); + } + } catch (Exception error) { + Log.w(TAG, "Client handling failed", error); + } + } + + private boolean isAuthorized(String path) { + return path != null && path.contains("token=" + token); + } + + private void handleWebSocket(Socket socket, String websocketKey) throws Exception { + OutputStream out = socket.getOutputStream(); + String accept = Base64.encodeToString( + MessageDigest.getInstance("SHA-1").digest((websocketKey + WS_GUID).getBytes(StandardCharsets.UTF_8)), + Base64.NO_WRAP + ); + String response = "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n"; + out.write(response.getBytes(StandardCharsets.UTF_8)); + out.flush(); + + InputStream in = socket.getInputStream(); + while (running && !socket.isClosed()) { + String message = readWebSocketText(in); + if (message == null) break; + dispatchMessage(message); + } + } + + private String readWebSocketText(InputStream in) throws IOException { + int b1 = in.read(); + if (b1 < 0) return null; + int b2 = in.read(); + if (b2 < 0) return null; + int opcode = b1 & 0x0F; + if (opcode == 8) return null; + boolean masked = (b2 & 0x80) != 0; + long length = b2 & 0x7F; + if (length == 126) length = ((long) in.read() << 8) | in.read(); + else if (length == 127) { + length = 0; + for (int i = 0; i < 8; i++) length = (length << 8) | in.read(); + } + if (length < 0 || length > 64 * 1024) return null; + byte[] mask = new byte[4]; + if (masked) readFully(in, mask); + byte[] data = new byte[(int) length]; + readFully(in, data); + if (masked) { + for (int i = 0; i < data.length; i++) data[i] = (byte) (data[i] ^ mask[i % 4]); + } + if (opcode != 1) return ""; + return new String(data, StandardCharsets.UTF_8); + } + + private void readFully(InputStream in, byte[] data) throws IOException { + int offset = 0; + while (offset < data.length) { + int read = in.read(data, offset, data.length - offset); + if (read < 0) throw new IOException("Unexpected EOF"); + offset += read; + } + } + + private void dispatchMessage(String message) { + try { + JSONObject json = new JSONObject(message); + String type = json.optString("type"); + JSONObject command = json.optJSONObject("command"); + if (command == null) command = json; + if ("key".equals(type)) { + handler.onRemoteKey(command.optString("key"), command.optBoolean("repeat", false), command.optString("digit", null)); + } else if ("text".equals(type)) { + handler.onRemoteText(command.optString("mode"), command.optString("text", "")); + } + } catch (Exception error) { + Log.w(TAG, "Invalid remote message: " + message, error); + } + } + + private void writeText(OutputStream out, int status, String contentType, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + String statusText = status == 200 ? "OK" : status == 403 ? "Forbidden" : "Not Found"; + String header = "HTTP/1.1 " + status + " " + statusText + "\r\n" + + "Content-Type: " + contentType + "\r\n" + + "Content-Length: " + bytes.length + "\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n\r\n"; + out.write(header.getBytes(StandardCharsets.UTF_8)); + out.write(bytes); + out.flush(); + } + + private static String getLocalIpAddress() { + try { + for (NetworkInterface networkInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!networkInterface.isUp() || networkInterface.isLoopback()) continue; + for (InetAddress address : Collections.list(networkInterface.getInetAddresses())) { + if (!address.isLoopbackAddress() && address instanceof Inet4Address) { + String ip = address.getHostAddress(); + if (!ip.startsWith("127.")) return ip; + } + } + } + } catch (Exception error) { + Log.w(TAG, "Unable to resolve local IP", error); + } + return null; + } + + private static String createToken() { + byte[] data = new byte[5]; + new SecureRandom().nextBytes(data); + return Base64.encodeToString(data, Base64.NO_WRAP | Base64.URL_SAFE).replace("=", ""); + } + + private String remoteHtml() { + return "" + + "MoonTVPlus 局域网遥控器
LAN REMOTE

电视遥控器

同一局域网直连 MoonTVPlus TV,低延迟控制方向、播放和文本输入。

正在连接电视...
"; + } +} diff --git a/apps/android-tv/app/src/main/java/com/moontvplus/tv/RemoteCommandHandler.java b/apps/android-tv/app/src/main/java/com/moontvplus/tv/RemoteCommandHandler.java new file mode 100644 index 0000000..b22c585 --- /dev/null +++ b/apps/android-tv/app/src/main/java/com/moontvplus/tv/RemoteCommandHandler.java @@ -0,0 +1,6 @@ +package com.moontvplus.tv; + +public interface RemoteCommandHandler { + void onRemoteKey(String key, boolean repeat, String digit); + void onRemoteText(String mode, String text); +} diff --git a/apps/android-tv/app/src/webview/java/com/moontvplus/tv/MainActivity.java b/apps/android-tv/app/src/webview/java/com/moontvplus/tv/MainActivity.java index 3ef46da..3eded60 100644 --- a/apps/android-tv/app/src/webview/java/com/moontvplus/tv/MainActivity.java +++ b/apps/android-tv/app/src/webview/java/com/moontvplus/tv/MainActivity.java @@ -5,10 +5,14 @@ import android.app.Activity; import android.graphics.Bitmap; import android.net.http.SslError; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.view.View; +import android.view.KeyEvent; import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; +import android.webkit.JavascriptInterface; import android.webkit.SslErrorHandler; import android.webkit.WebChromeClient; import android.webkit.WebResourceError; @@ -17,12 +21,18 @@ import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.FrameLayout; +import android.widget.TextView; -public class MainActivity extends Activity { +import java.net.URLEncoder; + +public class MainActivity extends Activity implements RemoteCommandHandler { private FrameLayout root; private WebView webView; private View customView; private WebChromeClient.CustomViewCallback customViewCallback; + private LocalRemoteServer localRemoteServer; + private TextView remoteHintView; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); @Override protected void onCreate(Bundle savedInstanceState) { @@ -41,7 +51,8 @@ public class MainActivity extends Activity { root = new FrameLayout(this); setContentView(root); setupWebView(); - webView.loadUrl(buildTvUrl(BuildConfig.BASE_URL)); + setupLocalRemoteServer(); + webView.loadUrl(withLocalRemoteHash(buildTvUrl(BuildConfig.BASE_URL))); } @SuppressLint("SetJavaScriptEnabled") @@ -50,6 +61,7 @@ public class MainActivity extends Activity { webView.setFocusable(true); webView.setFocusableInTouchMode(true); webView.requestFocus(); + webView.addJavascriptInterface(new LocalRemoteBridge(), "MoonTVLocalRemote"); root.addView(webView, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT @@ -88,6 +100,12 @@ public class MainActivity extends Activity { super.onPageStarted(view, url, favicon); } + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + injectLocalRemoteInfo(); + } + @Override public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { handler.cancel(); @@ -117,6 +135,131 @@ public class MainActivity extends Activity { }); } + + + private class LocalRemoteBridge { + @JavascriptInterface + public String getRemoteUrl() { + return localRemoteServer == null ? "" : String.valueOf(localRemoteServer.getRemoteUrl()); + } + + @JavascriptInterface + public int getPort() { + return localRemoteServer == null ? -1 : localRemoteServer.getPort(); + } + + @JavascriptInterface + public void showHint() { + mainHandler.post(() -> showLocalRemoteHint(true)); + } + } + + private void injectLocalRemoteInfo() { + if (webView == null || localRemoteServer == null) return; + String url = localRemoteServer.getRemoteUrl(); + if (url == null) return; + String safeUrl = url.replace("\\", "\\\\").replace("'", "\\'"); + String script = "window.__MOONTV_LOCAL_REMOTE_URL='" + safeUrl + "';" + + "window.dispatchEvent(new CustomEvent('moontv:local-remote-info',{detail:{url:'" + safeUrl + "'}}));"; + 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) { + if ("up".equals(key)) return KeyEvent.KEYCODE_DPAD_UP; + if ("down".equals(key)) return KeyEvent.KEYCODE_DPAD_DOWN; + if ("left".equals(key)) return KeyEvent.KEYCODE_DPAD_LEFT; + if ("right".equals(key)) return KeyEvent.KEYCODE_DPAD_RIGHT; + if ("ok".equals(key)) return KeyEvent.KEYCODE_DPAD_CENTER; + if ("back".equals(key)) return KeyEvent.KEYCODE_BACK; + if ("menu".equals(key)) return KeyEvent.KEYCODE_MENU; + if ("home".equals(key)) return KeyEvent.KEYCODE_HOME; + if ("playPause".equals(key)) return KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; + if ("pageUp".equals(key)) return KeyEvent.KEYCODE_PAGE_UP; + if ("pageDown".equals(key)) return KeyEvent.KEYCODE_PAGE_DOWN; + if ("digit".equals(key) && digit != null && digit.length() == 1 && digit.charAt(0) >= '0' && digit.charAt(0) <= '9') { + return KeyEvent.KEYCODE_0 + (digit.charAt(0) - '0'); + } + return KeyEvent.KEYCODE_UNKNOWN; + } + + @Override + public void onRemoteKey(String key, boolean repeat, String digit) { + int keyCode = keyCodeForRemoteKey(key, digit); + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) return; + mainHandler.post(() -> { + if (keyCode == KeyEvent.KEYCODE_BACK) { + onBackPressed(); + return; + } + long now = System.currentTimeMillis(); + KeyEvent down = new KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, repeat ? 1 : 0); + KeyEvent up = new KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0); + if (customView != null) { + customView.dispatchKeyEvent(down); + customView.dispatchKeyEvent(up); + } else if (webView != null) { + webView.dispatchKeyEvent(down); + webView.dispatchKeyEvent(up); + } + }); + } + + @Override + public void onRemoteText(String mode, String text) { + mainHandler.post(() -> { + if (webView == null) return; + String safeMode = mode == null ? "replace" : mode.replace("\\", "\\\\").replace("'", "\\'"); + String safeText = text == null ? "" : text.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n").replace("\r", ""); + String script = "window.dispatchEvent(new CustomEvent('moontv:local-remote-text',{detail:{mode:'" + safeMode + "',text:'" + safeText + "'}}));"; + webView.evaluateJavascript(script, null); + }); + } + + + private String withLocalRemoteHash(String url) { + String remoteUrl = localRemoteServer == null ? null : localRemoteServer.getRemoteUrl(); + if (remoteUrl == null || remoteUrl.isEmpty()) return url; + try { + return url + "#localRemoteUrl=" + URLEncoder.encode(remoteUrl, "UTF-8"); + } catch (Exception ignored) { + return url; + } + } + private static String buildTvUrl(String baseUrl) { String url = baseUrl == null ? "" : baseUrl.trim(); if (url.isEmpty()) { @@ -178,6 +321,10 @@ public class MainActivity extends Activity { @Override protected void onDestroy() { + if (localRemoteServer != null) { + localRemoteServer.stop(); + localRemoteServer = null; + } if (webView != null) { root.removeView(webView); webView.destroy(); diff --git a/src/app/tv/me/page.tsx b/src/app/tv/me/page.tsx index 68d9caf..eec25eb 100644 --- a/src/app/tv/me/page.tsx +++ b/src/app/tv/me/page.tsx @@ -3,12 +3,15 @@ import { BadgeCheck, Clock3, + Copy, Loader2, LogOut, Menu, + QrCode, ShieldCheck, SlidersHorizontal, User, + Wifi, Volume2, } from 'lucide-react'; import { useRouter } from 'next/navigation'; @@ -30,6 +33,20 @@ import { import TVLayout from '@/components/tv/TVLayout'; +const LOCAL_REMOTE_URL_KEY = 'moontv_local_remote_url'; + +type MoonTVLocalRemoteBridge = { + getRemoteUrl?: () => string; + showHint?: () => void; +}; + +declare global { + interface Window { + MoonTVLocalRemote?: MoonTVLocalRemoteBridge; + __MOONTV_LOCAL_REMOTE_URL?: string; + } +} + type AuthInfo = { username?: string; role?: 'owner' | 'admin' | 'user'; @@ -71,6 +88,9 @@ export default function TVMePage() { const [authInfo, setAuthInfo] = useState(null); 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( DEFAULT_TV_PLAYER_UP_DOWN_ACTION ); @@ -84,6 +104,33 @@ export default function TVMePage() { setReady(true); }, []); + useEffect(() => { + const readLocalRemoteUrl = () => { + const bridgeUrl = window.MoonTVLocalRemote?.getRemoteUrl?.() || ''; + setNativeHintAvailable(Boolean(window.MoonTVLocalRemote?.showHint)); + setLocalRemoteUrl( + bridgeUrl || + window.__MOONTV_LOCAL_REMOTE_URL || + localStorage.getItem(LOCAL_REMOTE_URL_KEY) || + '' + ); + }; + + const onLocalRemoteInfo = (event: Event) => { + const detail = (event as CustomEvent<{ url?: string }>).detail; + setLocalRemoteUrl(detail?.url || ''); + }; + + readLocalRemoteUrl(); + window.addEventListener('moontv:local-remote-info', onLocalRemoteInfo); + const timer = window.setInterval(readLocalRemoteUrl, 1500); + + return () => { + window.removeEventListener('moontv:local-remote-info', onLocalRemoteInfo); + window.clearInterval(timer); + }; + }, []); + useEffect(() => { if (ready && !authInfo) { router.replace('/tv/login?redirect=/tv/me'); @@ -136,6 +183,22 @@ export default function TVMePage() { }); }; + + const showLocalRemoteHint = () => { + window.MoonTVLocalRemote?.showHint?.(); + }; + + const copyLocalRemoteUrl = async () => { + if (!localRemoteUrl) return; + try { + await navigator.clipboard?.writeText(localRemoteUrl); + setCopyStatus('已复制'); + } catch { + setCopyStatus('请手动输入电视上的地址'); + } + window.setTimeout(() => setCopyStatus(''), 2200); + }; + if (!ready || !authInfo) { return ( @@ -234,6 +297,76 @@ export default function TVMePage() { +
+
+
+
+
+ + 局域网直连 +
+

+ + 手机扫码遥控 +

+

+ 在同一 Wi‑Fi 下用手机打开遥控地址,不经过服务器,方向键和播放控制更低延迟。 +

+ + {localRemoteUrl ? ( +
+
遥控地址
+
+ {localRemoteUrl} +
+
+ {nativeHintAvailable && ( + + )} + +
+ {copyStatus && ( +
+ {copyStatus} +
+ )} +
+ ) : ( +
+ 当前页面未检测到 APK 内置局域网遥控服务。请使用新版 APK 打开电视端,或查看电视左上角的临时遥控地址。 +
+ )} +
+ + {localRemoteUrl && ( +
+ 局域网遥控地址二维码 +
+ 手机扫码打开遥控器 +
+
+ )} +
+
+
diff --git a/src/components/tv/TVRemoteReceiver.tsx b/src/components/tv/TVRemoteReceiver.tsx index 0f31154..a6270d6 100644 --- a/src/components/tv/TVRemoteReceiver.tsx +++ b/src/components/tv/TVRemoteReceiver.tsx @@ -14,6 +14,7 @@ import type { } from '@/lib/tv-remote-types'; const DEVICE_ID_KEY = 'moontv_tv_remote_device_id'; +const LOCAL_REMOTE_URL_KEY = 'moontv_local_remote_url'; type TVRemoteReceiverSingleton = { socket: Socket | null; @@ -49,8 +50,41 @@ function getDeviceName() { export default function TVRemoteReceiver() { useEffect(() => { + const syncLocalRemoteUrl = () => { + const hash = window.location.hash || ''; + const match = hash.match(/(?:^|[#&])localRemoteUrl=([^&]+)/); + if (!match?.[1]) return; + + try { + const url = decodeURIComponent(match[1]); + if (url.startsWith('http://') || url.startsWith('https://')) { + localStorage.setItem(LOCAL_REMOTE_URL_KEY, url); + window.__MOONTV_LOCAL_REMOTE_URL = url; + window.dispatchEvent(new CustomEvent('moontv:local-remote-info', { + detail: { url }, + })); + } + } catch {} + }; + + const onLocalRemoteText = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.mode) { + applyTVRemoteText(detail); + } + }; + + syncLocalRemoteUrl(); + window.addEventListener('hashchange', syncLocalRemoteUrl); + window.addEventListener('moontv:local-remote-text', onLocalRemoteText); + const auth = getAuthInfoFromBrowserCookie(); - if (!auth?.username) return; + if (!auth?.username) { + return () => { + window.removeEventListener('hashchange', syncLocalRemoteUrl); + window.removeEventListener('moontv:local-remote-text', onLocalRemoteText); + }; + } receiverState.refCount += 1; if (receiverState.disconnectTimer) { @@ -116,6 +150,8 @@ export default function TVRemoteReceiver() { window.clearInterval(interval); document.removeEventListener('visibilitychange', onVisibilityChange); window.removeEventListener('focus', updateState); + window.removeEventListener('hashchange', syncLocalRemoteUrl); + window.removeEventListener('moontv:local-remote-text', onLocalRemoteText); socket.off('connect', register); socket.off('tv-remote:key'); socket.off('tv-remote:text');