mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-14 20:01:58 +08:00
feat: Support remote input
This commit is contained in:
@@ -7,6 +7,8 @@ import { Platform } from "react-native";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { remoteControlService } from "@/services/remoteControlService";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -31,6 +33,23 @@ export default function RootLayout() {
|
||||
}
|
||||
}, [loaded, error]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize the service with store actions to break require cycle
|
||||
const { setMessage, hideModal } = useRemoteControlStore.getState();
|
||||
remoteControlService.init({
|
||||
onMessage: setMessage,
|
||||
onHandshake: hideModal,
|
||||
});
|
||||
|
||||
// Start the remote control server on app launch
|
||||
useRemoteControlStore.getState().startServer();
|
||||
|
||||
return () => {
|
||||
// Stop the server on app close
|
||||
useRemoteControlStore.getState().stopServer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!loaded && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ThemedView } from "@/components/ThemedView";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { AVPlaybackStatus } from "expo-av";
|
||||
|
||||
const M3U_URL = "https://raw.githubusercontent.com/fanmingming/live/refs/heads/main/tv/m3u/ipv6.m3u";
|
||||
const M3U_URL = "https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u";
|
||||
|
||||
export default function LiveScreen() {
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
|
||||
@@ -4,8 +4,10 @@ import { ThemedView } from "@/components/ThemedView";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import VideoCard from "@/components/VideoCard.tv";
|
||||
import { api, SearchResult } from "@/services/api";
|
||||
import { Search } from "lucide-react-native";
|
||||
import { Search, QrCode } from "lucide-react-native";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { RemoteControlModal } from "@/components/RemoteControlModal";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -15,6 +17,15 @@ export default function SearchScreen() {
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
const colorScheme = "dark"; // Replace with useColorScheme() if needed
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const { showModal: showRemoteModal, lastMessage } = useRemoteControlStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (lastMessage) {
|
||||
const realMessage = lastMessage.split("_")[0];
|
||||
setKeyword(realMessage);
|
||||
handleSearch(realMessage);
|
||||
}
|
||||
}, [lastMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the text input when the screen loads
|
||||
@@ -24,8 +35,9 @@ export default function SearchScreen() {
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!keyword.trim()) {
|
||||
const handleSearch = async (searchText?: string) => {
|
||||
const term = typeof searchText === "string" ? searchText : keyword;
|
||||
if (!term.trim()) {
|
||||
Keyboard.dismiss();
|
||||
return;
|
||||
}
|
||||
@@ -33,7 +45,7 @@ export default function SearchScreen() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.searchVideos(keyword);
|
||||
const response = await api.searchVideos(term);
|
||||
if (response.results.length > 0) {
|
||||
setResults(response.results);
|
||||
} else {
|
||||
@@ -47,6 +59,8 @@ export default function SearchScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchPress = () => handleSearch();
|
||||
|
||||
const renderItem = ({ item }: { item: SearchResult }) => (
|
||||
<VideoCard
|
||||
id={item.id.toString()}
|
||||
@@ -78,12 +92,15 @@ export default function SearchScreen() {
|
||||
onChangeText={setKeyword}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onSubmitEditing={handleSearch} // Allow searching with remote 'enter' button
|
||||
onSubmitEditing={onSearchPress}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
<StyledButton style={styles.searchButton} onPress={handleSearch}>
|
||||
<StyledButton style={styles.searchButton} onPress={onSearchPress}>
|
||||
<Search size={24} color={colorScheme === "dark" ? "white" : "black"} />
|
||||
</StyledButton>
|
||||
<StyledButton style={styles.qrButton} onPress={showRemoteModal}>
|
||||
<QrCode size={24} color={colorScheme === "dark" ? "white" : "black"} />
|
||||
</StyledButton>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -108,6 +125,7 @@ export default function SearchScreen() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<RemoteControlModal />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +158,11 @@ const styles = StyleSheet.create({
|
||||
// backgroundColor is now set dynamically
|
||||
borderRadius: 8,
|
||||
},
|
||||
qrButton: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginLeft: 10,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
82
components/RemoteControlModal.tsx
Normal file
82
components/RemoteControlModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { Modal, View, Text, StyleSheet } from "react-native";
|
||||
import QRCode from "react-native-qrcode-svg";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { ThemedView } from "./ThemedView";
|
||||
import { ThemedText } from "./ThemedText";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
|
||||
export const RemoteControlModal: React.FC = () => {
|
||||
const { isModalVisible, hideModal, serverUrl, error } = useRemoteControlStore();
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent={true} visible={isModalVisible} onRequestClose={hideModal}>
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView style={styles.modalContent}>
|
||||
<ThemedText style={styles.title}>手机扫码</ThemedText>
|
||||
<View style={styles.qrContainer}>
|
||||
{serverUrl ? (
|
||||
<>
|
||||
<QRCode value={serverUrl} size={200} backgroundColor="white" color="black" />
|
||||
</>
|
||||
) : (
|
||||
<ThemedText style={styles.statusText}>{error ? `错误: ${error}` : "正在生成二维码..."}</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
<ThemedText style={styles.instructions}>
|
||||
使用手机扫描上方二维码,即可在浏览器中向 TV 发送消息。或者访问{serverUrl}
|
||||
</ThemedText>
|
||||
<StyledButton text="关闭" onPress={hideModal} style={styles.button} variant="primary" />
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
},
|
||||
modalContent: {
|
||||
width: "85%",
|
||||
maxWidth: 400,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 10,
|
||||
paddingTop: 10,
|
||||
},
|
||||
qrContainer: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderRadius: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
statusText: {
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
},
|
||||
serverUrlText: {
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
},
|
||||
instructions: {
|
||||
textAlign: "center",
|
||||
marginBottom: 24,
|
||||
fontSize: 16,
|
||||
color: "#ccc",
|
||||
},
|
||||
button: {
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
136
docs/REMOTE_CONTROL_FEATURE.md
Normal file
136
docs/REMOTE_CONTROL_FEATURE.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 手机遥控功能实现方案 (V2)
|
||||
|
||||
本文档详细描述了在 OrionTV 应用中集成一个基于 **HTTP 请求** 的手机遥控功能的完整方案。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心功能与流程
|
||||
|
||||
该功能允许用户通过手机浏览器向 TV 端发送文本消息,TV 端接收后以 Toast 形式进行展示。服务将在应用启动时自动开启,用户可在设置中找到入口以显示连接二维码。
|
||||
|
||||
### 流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as App 启动
|
||||
participant RemoteControlStore as 状态管理 (TV)
|
||||
participant RemoteControlService as 遥控服务 (TV)
|
||||
participant User as 用户
|
||||
participant SettingsUI as 设置界面 (TV)
|
||||
participant PhoneBrowser as 手机浏览器
|
||||
|
||||
App->>RemoteControlStore: App 启动, 触发 startHttpServer()
|
||||
RemoteControlStore->>RemoteControlService: 启动 HTTP 服务
|
||||
RemoteControlService-->>RemoteControlStore: 更新服务状态 (IP, Port)
|
||||
|
||||
User->>SettingsUI: 打开设置, 点击“手机遥控”按钮
|
||||
SettingsUI->>RemoteControlStore: 获取服务 URL
|
||||
RemoteControlStore-->>SettingsUI: 返回 serverUrl
|
||||
SettingsUI-->>User: 显示二维码弹窗
|
||||
|
||||
User->>PhoneBrowser: 扫描二维码
|
||||
PhoneBrowser->>RemoteControlService: (HTTP GET) 请求网页
|
||||
RemoteControlService-->>PhoneBrowser: 返回 HTML 页面
|
||||
User->>PhoneBrowser: 输入文本并发送
|
||||
PhoneBrowser->>RemoteControlService: (HTTP POST /message) 发送消息
|
||||
RemoteControlService->>RemoteControlStore: 处理消息 (显示 Toast)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
* **HTTP 服务**: `react-native-http-bridge`
|
||||
* **二维码生成**: `react-native-qrcode-svg`
|
||||
* **网络信息 (IP 地址)**: `@react-native-community/netinfo`
|
||||
* **状态管理**: `zustand` (项目已集成)
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构变更
|
||||
|
||||
### 新增文件
|
||||
|
||||
* `services/remoteControlService.ts`: 封装 HTTP 服务的核心逻辑。
|
||||
* `stores/remoteControlStore.ts`: 使用 Zustand 管理远程控制服务的状态。
|
||||
* `components/RemoteControlModal.tsx`: 显示二维码和连接信息的弹窗组件。
|
||||
* `types/react-native-http-bridge.d.ts`: `react-native-http-bridge` 的 TypeScript 类型定义。
|
||||
|
||||
### 修改文件
|
||||
|
||||
* `app/_layout.tsx`: 在应用根组件中调用服务启动逻辑。
|
||||
* `components/SettingsModal.tsx`: 添加“手机遥控”按钮,用于触发二维码弹窗。
|
||||
* `package.json`: 添加新依赖。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现细节
|
||||
|
||||
### a. 状态管理 (`stores/remoteControlStore.ts`)
|
||||
|
||||
创建一个 Zustand store 来管理遥控服务的状态。
|
||||
|
||||
* **State**:
|
||||
* `isServerRunning`: `boolean` - 服务是否正在运行。
|
||||
* `serverUrl`: `string | null` - 完整的 HTTP 服务 URL (e.g., `http://192.168.1.5:12346`)。
|
||||
* `error`: `string | null` - 错误信息。
|
||||
* **Actions**:
|
||||
* `startServer()`: 异步 action,调用 `remoteControlService.startServer` 并更新 state。
|
||||
* `stopServer()`: 调用 `remoteControlService.stopServer` 并更新 state。
|
||||
|
||||
### b. 服务层 (`services/remoteControlService.ts`)
|
||||
|
||||
实现服务的启动、停止和消息处理。
|
||||
|
||||
* **`startServer()`**:
|
||||
1. 使用 `@react-native-community/netinfo` 获取 IP 地址。
|
||||
2. 定义一个包含 `fetch` API 调用逻辑的 HTML 字符串。
|
||||
3. 使用 `react-native-http-bridge` 在固定端口(如 `12346`)启动 HTTP 服务。
|
||||
4. 配置 `GET /` 路由以返回 HTML 页面。
|
||||
5. 配置 `POST /message` 路由来接收手机端发送的消息,并使用 `Toast` 显示。
|
||||
6. 返回服务器 URL。
|
||||
* **`stopServer()`**:
|
||||
1. 调用 `httpBridge.stop()`。
|
||||
|
||||
### c. UI 集成
|
||||
|
||||
* **`app/_layout.tsx`**:
|
||||
* 在根组件 `useEffect` 中调用 `useRemoteControlStore.getState().startServer()`,实现服务自启。
|
||||
* **`components/SettingsModal.tsx`**:
|
||||
* 添加一个 `<StyledButton text="手机遥控" />`。
|
||||
* 点击按钮时,触发 `RemoteControlModal` 的显示。
|
||||
* **`components/RemoteControlModal.tsx`**:
|
||||
* 从 `remoteControlStore` 中获取 `serverUrl`。
|
||||
* 如果 `serverUrl` 存在,则使用 `react-native-qrcode-svg` 的 `<QRCode />` 组件显示二维码。
|
||||
* 如果不存在,则显示加载中或错误信息。
|
||||
|
||||
### d. 网页内容 (HTML)
|
||||
|
||||
一个简单的 HTML 页面,包含一个输入框和一个按钮。
|
||||
|
||||
```html
|
||||
<html>
|
||||
<head>
|
||||
<title>OrionTV Remote</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style> /* ... some basic styles ... */ </style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>发送消息到 TV</h3>
|
||||
<input id="text" />
|
||||
<button onclick="send()">发送</button>
|
||||
<script>
|
||||
function send() {
|
||||
const val = document.getElementById("text").value;
|
||||
if (val) {
|
||||
fetch("/message", {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: val })
|
||||
});
|
||||
document.getElementById("text").value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.1.1",
|
||||
"scripts": {
|
||||
"postinstall": "node ./scripts/patch-http-bridge.js",
|
||||
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
"android": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
|
||||
@@ -26,8 +27,10 @@
|
||||
"react-native": "npm:react-native-tvos@~0.74.2-0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-native-http-bridge": "^0.4.0",
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/netinfo": "^11.3.2",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"expo": "~51.0.13",
|
||||
"expo-av": "~14.0.7",
|
||||
@@ -46,6 +49,7 @@
|
||||
"react-native": "npm:react-native-tvos@~0.74.2-0",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-media-console": "*",
|
||||
"react-native-qrcode-svg": "^6.3.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "3.31.1",
|
||||
|
||||
82
scripts/patch-http-bridge.js
Normal file
82
scripts/patch-http-bridge.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function patchFile(filePath, patches) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`File not found, skipping patch: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Patching ${filePath}...`);
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
patches.forEach(patch => {
|
||||
content = content.replace(patch.find, patch.replace);
|
||||
});
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
// --- Patch build.gradle ---
|
||||
const gradleFile = path.resolve(__dirname, '..', 'node_modules', 'react-native-http-bridge', 'android', 'build.gradle');
|
||||
patchFile(gradleFile, [
|
||||
{
|
||||
find: /jcenter\(\)/g,
|
||||
replace: 'google()\n mavenCentral()'
|
||||
},
|
||||
{
|
||||
find: "classpath 'com.android.tools.build:gradle:2.2.0'",
|
||||
replace: "classpath 'com.android.tools.build:gradle:7.3.1'"
|
||||
},
|
||||
{
|
||||
find: 'compileSdkVersion 23',
|
||||
replace: 'compileSdkVersion 33'
|
||||
},
|
||||
{
|
||||
find: 'buildToolsVersion "23.0.1"',
|
||||
replace: 'buildToolsVersion "33.0.0"'
|
||||
},
|
||||
{
|
||||
find: /compile /g,
|
||||
replace: 'implementation '
|
||||
},
|
||||
{
|
||||
find: /android {/,
|
||||
replace: 'android {\n namespace "me.alwx.HttpServer"'
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Patch AndroidManifest.xml ---
|
||||
const manifestFile = path.resolve(__dirname, '..', 'node_modules', 'react-native-http-bridge', 'android', 'src', 'main', 'AndroidManifest.xml');
|
||||
patchFile(manifestFile, [
|
||||
{
|
||||
find: /package="me.alwx.HttpServer"/,
|
||||
replace: ''
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Patch Server.java ---
|
||||
const serverJavaFile = path.resolve(__dirname, '..', 'node_modules', 'react-native-http-bridge', 'android', 'src', 'main', 'java', 'me', 'alwx', 'HttpServer', 'Server.java');
|
||||
patchFile(serverJavaFile, [
|
||||
{
|
||||
find: 'import android.support.annotation.Nullable;',
|
||||
replace: 'import androidx.annotation.Nullable;'
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Patch HttpServerReactPackage.java ---
|
||||
const packageJavaFile = path.resolve(__dirname, '..', 'node_modules', 'react-native-http-bridge', 'android', 'src', 'main', 'java', 'me', 'alwx', 'HttpServer', 'HttpServerReactPackage.java');
|
||||
patchFile(packageJavaFile, [
|
||||
{
|
||||
find: '@Override\n public List<Class<? extends JavaScriptModule>> createJSModules()',
|
||||
replace: 'public List<Class<? extends JavaScriptModule>> createJSModules()'
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Patch HttpServerModule.java for better logging ---
|
||||
const moduleJavaFile = path.resolve(__dirname, '..', 'node_modules', 'react-native-http-bridge', 'android', 'src', 'main', 'java', 'me', 'alwx', 'HttpServer', 'HttpServerModule.java');
|
||||
patchFile(moduleJavaFile, [
|
||||
{
|
||||
find: 'Log.e(MODULE_NAME, e.getMessage());',
|
||||
replace: 'Log.e(MODULE_NAME, "Failed to start server", e);'
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('Finished patching react-native-http-bridge.');
|
||||
132
services/remoteControlService.ts
Normal file
132
services/remoteControlService.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import httpBridge from 'react-native-http-bridge';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
const PORT = 12346;
|
||||
|
||||
const getRemotePageHTML = () => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OrionTV Remote</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #121212; color: white; }
|
||||
h3 { color: #eee; }
|
||||
#container { display: flex; flex-direction: column; align-items: center; width: 90%; max-width: 400px; }
|
||||
#text { width: 100%; padding: 15px; font-size: 16px; border-radius: 8px; border: 1px solid #333; background-color: #2a2a2a; color: white; margin-bottom: 20px; box-sizing: border-box; }
|
||||
button { width: 100%; padding: 15px; font-size: 18px; font-weight: bold; border: none; border-radius: 8px; background-color: #007AFF; color: white; cursor: pointer; }
|
||||
button:active { background-color: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<h3>Send a message to TV</h3>
|
||||
<input id="text" placeholder="Type here..." />
|
||||
<button onclick="send()">Send</button>
|
||||
</div>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/handshake', { method: 'POST' }).catch(console.error);
|
||||
});
|
||||
function send() {
|
||||
const input = document.getElementById("text");
|
||||
const value = input.value;
|
||||
if (value) {
|
||||
fetch("/message", {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: value })
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
class RemoteControlService {
|
||||
private isRunning = false;
|
||||
private onMessage: (message: string) => void = () => {};
|
||||
private onHandshake: () => void = () => {};
|
||||
|
||||
public init(actions: { onMessage: (message: string) => void; onHandshake: () => void }) {
|
||||
this.onMessage = actions.onMessage;
|
||||
this.onHandshake = actions.onHandshake;
|
||||
}
|
||||
|
||||
public async startServer(): Promise<string> {
|
||||
console.log('[RemoteControl] Attempting to start server...');
|
||||
if (this.isRunning) {
|
||||
console.log('[RemoteControl] Server is already running.');
|
||||
throw new Error('Server is already running.');
|
||||
}
|
||||
|
||||
const netState = await NetInfo.fetch();
|
||||
console.log('[RemoteControl] NetInfo state:', JSON.stringify(netState, null, 2));
|
||||
let ipAddress: string | null = null;
|
||||
if (netState.type === 'wifi' || netState.type === 'ethernet') {
|
||||
ipAddress = (netState.details as any)?.ipAddress ?? null;
|
||||
}
|
||||
|
||||
if (!ipAddress) {
|
||||
console.error('[RemoteControl] Could not get IP address.');
|
||||
throw new Error('无法获取IP地址,请确认设备已连接到WiFi或以太网。');
|
||||
}
|
||||
console.log(`[RemoteControl] Got IP address: ${ipAddress}`);
|
||||
|
||||
try {
|
||||
// The third argument to start() is the request handler, not a startup callback.
|
||||
httpBridge.start(
|
||||
PORT,
|
||||
'OrionTVRemoteService',
|
||||
(request: { url: string; type: string; requestId: string; postData: string }) => {
|
||||
const { url, type: method, requestId, postData: body } = request;
|
||||
|
||||
if (method === 'GET' && url === '/') {
|
||||
const html = getRemotePageHTML();
|
||||
httpBridge.respond(requestId, 200, 'text/html', html);
|
||||
} else if (method === 'POST' && url === '/message') {
|
||||
try {
|
||||
const parsedBody = JSON.parse(body);
|
||||
const message = parsedBody.message;
|
||||
if (message) {
|
||||
this.onMessage(message);
|
||||
}
|
||||
httpBridge.respond(requestId, 200, 'application/json', JSON.stringify({ status: 'ok' }));
|
||||
} catch (e) {
|
||||
httpBridge.respond(requestId, 400, 'application/json', JSON.stringify({ error: 'Bad Request' }));
|
||||
}
|
||||
} else if (method === 'POST' && url === '/handshake') {
|
||||
this.onHandshake();
|
||||
httpBridge.respond(requestId, 200, 'application/json', JSON.stringify({ status: 'ok' }));
|
||||
} else {
|
||||
httpBridge.respond(requestId, 404, 'text/plain', 'Not Found');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[RemoteControl] http-bridge start command issued.');
|
||||
this.isRunning = true;
|
||||
const url = `http://${ipAddress}:${PORT}`;
|
||||
console.log(`[RemoteControl] Server should be running at: ${url}`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error('[RemoteControl] Failed to issue start command to http-bridge.', error);
|
||||
this.isRunning = false;
|
||||
throw new Error(error instanceof Error ? error.message : 'Failed to start server');
|
||||
}
|
||||
}
|
||||
|
||||
public stopServer() {
|
||||
if (this.isRunning) {
|
||||
httpBridge.stop();
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const remoteControlService = new RemoteControlService();
|
||||
52
stores/remoteControlStore.ts
Normal file
52
stores/remoteControlStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { create } from 'zustand';
|
||||
import { remoteControlService } from '@/services/remoteControlService';
|
||||
|
||||
interface RemoteControlState {
|
||||
isServerRunning: boolean;
|
||||
serverUrl: string | null;
|
||||
error: string | null;
|
||||
startServer: () => Promise<void>;
|
||||
stopServer: () => void;
|
||||
isModalVisible: boolean;
|
||||
showModal: () => void;
|
||||
hideModal: () => void;
|
||||
lastMessage: string | null;
|
||||
setMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
export const useRemoteControlStore = create<RemoteControlState>((set, get) => ({
|
||||
isServerRunning: false,
|
||||
serverUrl: null,
|
||||
error: null,
|
||||
isModalVisible: false,
|
||||
lastMessage: null,
|
||||
|
||||
startServer: async () => {
|
||||
if (get().isServerRunning) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = await remoteControlService.startServer();
|
||||
console.log(`[RemoteControlStore] Server started, URL: ${url}`);
|
||||
set({ isServerRunning: true, serverUrl: url, error: null });
|
||||
} catch (e: any) {
|
||||
const errorMessage = e.message || 'Failed to start server';
|
||||
console.error('[RemoteControlStore] Failed to start server:', errorMessage);
|
||||
set({ error: errorMessage });
|
||||
}
|
||||
},
|
||||
|
||||
stopServer: () => {
|
||||
if (get().isServerRunning) {
|
||||
remoteControlService.stopServer();
|
||||
set({ isServerRunning: false, serverUrl: null });
|
||||
}
|
||||
},
|
||||
|
||||
showModal: () => set({ isModalVisible: true }),
|
||||
hideModal: () => set({ isModalVisible: false }),
|
||||
|
||||
setMessage: (message: string) => {
|
||||
set({ lastMessage: `${message}_${Date.now()}` });
|
||||
},
|
||||
}));
|
||||
13
types/react-native-http-bridge.d.ts
vendored
Normal file
13
types/react-native-http-bridge.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
declare module 'react-native-http-bridge' {
|
||||
import { EmitterSubscription } from 'react-native';
|
||||
|
||||
interface HttpBridge {
|
||||
start(port: number, serviceName: string, callback: (request: { url: string; type: string; requestId: string; postData: string }) => void): void;
|
||||
stop(): void;
|
||||
on(event: 'request', callback: (request: any) => void): EmitterSubscription;
|
||||
respond(requestId: string, code: number, type: string, body: string): void;
|
||||
}
|
||||
|
||||
const httpBridge: HttpBridge;
|
||||
export default httpBridge;
|
||||
}
|
||||
9096
yarn-error.log
Normal file
9096
yarn-error.log
Normal file
File diff suppressed because it is too large
Load Diff
47
yarn.lock
47
yarn.lock
@@ -1670,6 +1670,11 @@
|
||||
prompts "^2.4.2"
|
||||
semver "^7.5.2"
|
||||
|
||||
"@react-native-community/netinfo@^11.3.2":
|
||||
version "11.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
|
||||
integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==
|
||||
|
||||
"@react-native-tvos/config-tv@^0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295"
|
||||
@@ -3549,6 +3554,11 @@ diff-sequences@^29.6.3:
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921"
|
||||
integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==
|
||||
|
||||
dijkstrajs@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
||||
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
|
||||
|
||||
dir-glob@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
|
||||
@@ -6830,6 +6840,11 @@ pngjs@^3.3.0:
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
|
||||
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
|
||||
|
||||
pngjs@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
|
||||
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
|
||||
|
||||
possible-typed-array-names@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
|
||||
@@ -6920,7 +6935,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2, prompts@^2.4.2:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
prop-types@^15.7.2:
|
||||
prop-types@^15.7.2, prop-types@^15.8.0:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@@ -6959,6 +6974,15 @@ qrcode-terminal@0.11.0:
|
||||
resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e"
|
||||
integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==
|
||||
|
||||
qrcode@^1.5.4:
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88"
|
||||
integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
|
||||
dependencies:
|
||||
dijkstrajs "^1.0.1"
|
||||
pngjs "^5.0.0"
|
||||
yargs "^15.3.1"
|
||||
|
||||
query-string@^7.1.3:
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
|
||||
@@ -7067,11 +7091,25 @@ react-native-helmet-async@2.0.4:
|
||||
react-fast-compare "^3.2.2"
|
||||
shallowequal "^1.1.0"
|
||||
|
||||
react-native-http-bridge@^0.4.0:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-http-bridge/-/react-native-http-bridge-0.4.1.tgz#d120a25b23fb98ec708def75fef5489d79173e81"
|
||||
integrity sha512-a2NSuLMh2vxwr1aqmrHGZoXkRM78YXTzX5i9AffkGXLLL3ssLLpDYgsU3HD9S+aaJu+jbDjoxwuZKYuIKJPznw==
|
||||
|
||||
react-native-media-console@*:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native-media-console/-/react-native-media-console-2.2.4.tgz#76a232cdcb645cfdb25bacddee514f360eb4947d"
|
||||
integrity sha512-CpOunVkGkMRg7DoGYlXfGITbSuqvCId7CFcWyDED3CjJ1CCym4dB670GiBNymH/Sh5p1AaW8kvHng7PZ7fYUBQ==
|
||||
|
||||
react-native-qrcode-svg@^6.3.1:
|
||||
version "6.3.15"
|
||||
resolved "https://registry.yarnpkg.com/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.15.tgz#20d7a189dff5b7ee8a75222a1268a805497cac75"
|
||||
integrity sha512-vLuNImGfstE8u+rlF4JfFpq65nPhmByuDG6XUPWh8yp8MgLQX11rN5eQ8nb/bf4OB+V8XoLTJB/AZF2g7jQSSQ==
|
||||
dependencies:
|
||||
prop-types "^15.8.0"
|
||||
qrcode "^1.5.4"
|
||||
text-encoding "^0.7.0"
|
||||
|
||||
react-native-reanimated@~3.10.1:
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.10.1.tgz#3c37d1100bbba0065df39c96aab0c1ff1b50c0fa"
|
||||
@@ -8172,6 +8210,11 @@ test-exclude@^6.0.0:
|
||||
glob "^7.1.4"
|
||||
minimatch "^3.0.4"
|
||||
|
||||
text-encoding@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643"
|
||||
integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==
|
||||
|
||||
text-table@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||
@@ -8889,7 +8932,7 @@ yargs-parser@^21.1.1:
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
|
||||
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
|
||||
|
||||
yargs@^15.1.0:
|
||||
yargs@^15.1.0, yargs@^15.3.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
|
||||
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
|
||||
|
||||
Reference in New Issue
Block a user