mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
feat: Refactor settings management into a dedicated page with new configuration options
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
"Bash(rm:*)",
|
||||
"Bash(yarn install)",
|
||||
"Bash(yarn lint)",
|
||||
"Bash(yarn prebuild-tv:*)"
|
||||
"Bash(yarn prebuild-tv:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(yarn lint:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -18,11 +18,12 @@ export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
});
|
||||
const initializeSettings = useSettingsStore((state) => state.loadSettings);
|
||||
const { loadSettings, remoteInputEnabled } = useSettingsStore();
|
||||
const { startServer, stopServer } = useRemoteControlStore();
|
||||
|
||||
useEffect(() => {
|
||||
initializeSettings();
|
||||
}, [initializeSettings]);
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || error) {
|
||||
@@ -34,21 +35,12 @@ 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 (remoteInputEnabled) {
|
||||
startServer();
|
||||
} else {
|
||||
stopServer();
|
||||
}
|
||||
}, [remoteInputEnabled, startServer, stopServer]);
|
||||
|
||||
if (!loaded && !error) {
|
||||
return null;
|
||||
@@ -62,6 +54,7 @@ export default function RootLayout() {
|
||||
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
|
||||
<Stack.Screen name="search" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="live" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toast />
|
||||
|
||||
@@ -6,10 +6,8 @@ import { api } from "@/services/api";
|
||||
import VideoCard from "@/components/VideoCard.tv";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { Search, Settings } from "lucide-react-native";
|
||||
import { SettingsModal } from "@/components/SettingsModal";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const NUM_COLUMNS = 5;
|
||||
const { width } = Dimensions.get("window");
|
||||
@@ -34,8 +32,6 @@ export default function HomeScreen() {
|
||||
refreshPlayRecords,
|
||||
} = useHomeStore();
|
||||
|
||||
const showSettingsModal = useSettingsStore((state) => state.showModal);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshPlayRecords();
|
||||
@@ -52,14 +48,14 @@ export default function HomeScreen() {
|
||||
setSelectedTag(defaultTag);
|
||||
selectCategory({ ...selectedCategory, tag: defaultTag });
|
||||
}
|
||||
}, [selectedCategory, fetchInitialData]);
|
||||
}, [selectedCategory, fetchInitialData, selectCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategory && selectedCategory.tag) {
|
||||
fetchInitialData();
|
||||
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
|
||||
}
|
||||
}, [selectedCategory?.tag]);
|
||||
}, [fetchInitialData, selectedCategory, selectedCategory.tag]);
|
||||
|
||||
const handleCategorySelect = (category: Category) => {
|
||||
setSelectedTag(null);
|
||||
@@ -133,7 +129,7 @@ export default function HomeScreen() {
|
||||
>
|
||||
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||
</StyledButton>
|
||||
<StyledButton style={styles.searchButton} onPress={showSettingsModal} variant="ghost">
|
||||
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
|
||||
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||
</StyledButton>
|
||||
</View>
|
||||
@@ -207,7 +203,6 @@ export default function HomeScreen() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingsModal />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
98
app/settings.tsx
Normal file
98
app/settings.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, StyleSheet, ScrollView, Alert } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { APIConfigSection } from "@/components/settings/APIConfigSection";
|
||||
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
|
||||
import { RemoteInputSection } from "@/components/settings/RemoteInputSection";
|
||||
import { PlaySourceSection } from "@/components/settings/PlaybackSourceSection";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { loadSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await saveSettings();
|
||||
setHasChanges(false);
|
||||
Alert.alert("成功", "设置已保存");
|
||||
} catch {
|
||||
Alert.alert("错误", "保存设置失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsChanged = () => {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.title}>设置</ThemedText>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
<RemoteInputSection onChanged={markAsChanged} />
|
||||
<APIConfigSection onChanged={markAsChanged} />
|
||||
<LiveStreamSection onChanged={markAsChanged} />
|
||||
<PlaySourceSection onChanged={markAsChanged} />
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<StyledButton
|
||||
text={isLoading ? "保存中..." : "保存设置"}
|
||||
onPress={handleSave}
|
||||
variant="primary"
|
||||
disabled={!hasChanges || isLoading}
|
||||
style={[styles.saveButton, (!hasChanges || isLoading) && styles.disabledButton]}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: 24,
|
||||
},
|
||||
backButton: {
|
||||
minWidth: 100,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
},
|
||||
saveButton: {
|
||||
minHeight: 50,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Modal, View, Text, TextInput, StyleSheet } from "react-native";
|
||||
import { ThemedText } from "./ThemedText";
|
||||
import { ThemedView } from "./ThemedView";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
|
||||
export const SettingsModal: React.FC = () => {
|
||||
const { isModalVisible, hideModal, apiBaseUrl, setApiBaseUrl, saveSettings, loadSettings } = useSettingsStore();
|
||||
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const colorScheme = "dark"; // Replace with useColorScheme() if needed
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalVisible) {
|
||||
loadSettings();
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isModalVisible, loadSettings]);
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
modalContent: {
|
||||
width: "80%",
|
||||
maxWidth: 500,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
elevation: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 2,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 15,
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
backgroundColor: colorScheme === "dark" ? "#3a3a3c" : "#f0f0f0",
|
||||
color: colorScheme === "dark" ? "white" : "black",
|
||||
borderColor: "transparent",
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: "#007AFF",
|
||||
shadowColor: "#007AFF",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent={true} visible={isModalVisible} onRequestClose={hideModal}>
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView style={styles.modalContent}>
|
||||
<ThemedText style={styles.title}>设置</ThemedText>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, isInputFocused && styles.inputFocused]}
|
||||
value={apiBaseUrl}
|
||||
onChangeText={setApiBaseUrl}
|
||||
placeholder="输入 API 地址"
|
||||
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
/>
|
||||
<View style={styles.buttonContainer}>
|
||||
<StyledButton
|
||||
text="取消"
|
||||
onPress={hideModal}
|
||||
style={styles.button}
|
||||
textStyle={styles.buttonText}
|
||||
variant="default"
|
||||
/>
|
||||
<StyledButton
|
||||
text="保存"
|
||||
onPress={handleSave}
|
||||
style={styles.button}
|
||||
textStyle={styles.buttonText}
|
||||
variant="primary"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
81
components/settings/APIConfigSection.tsx
Normal file
81
components/settings/APIConfigSection.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { View, TextInput, StyleSheet } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
interface APIConfigSectionProps {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export const APIConfigSection: React.FC<APIConfigSectionProps> = ({ onChanged }) => {
|
||||
const { apiBaseUrl, setApiBaseUrl } = useSettingsStore();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setApiBaseUrl(url);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<View style={styles.inputContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>API 地址</ThemedText>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, isInputFocused && styles.inputFocused]}
|
||||
value={apiBaseUrl}
|
||||
onChangeText={handleUrlChange}
|
||||
placeholder="输入 API 地址"
|
||||
placeholderTextColor="#888"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#333",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 8,
|
||||
color: "#ccc",
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 2,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 15,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#3a3a3c",
|
||||
color: "white",
|
||||
borderColor: "transparent",
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: "#007AFF",
|
||||
shadowColor: "#007AFF",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
});
|
||||
37
components/settings/LiveStreamSection.tsx
Normal file
37
components/settings/LiveStreamSection.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
interface LiveStreamSectionProps {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export const LiveStreamSection: React.FC<LiveStreamSectionProps> = ({ onChanged }) => {
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>直播源配置</ThemedText>
|
||||
<ThemedText style={styles.placeholder}>直播源配置功能即将上线</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
placeholder: {
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
37
components/settings/PlaybackSourceSection.tsx
Normal file
37
components/settings/PlaybackSourceSection.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
|
||||
interface PlaybackSourceSectionProps {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export const PlaySourceSection: React.FC<PlaybackSourceSectionProps> = ({ onChanged }) => {
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>播放源配置</ThemedText>
|
||||
<ThemedText style={styles.placeholder}>播放源配置功能即将上线</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#333",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
},
|
||||
placeholder: {
|
||||
fontSize: 14,
|
||||
color: "#888",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
121
components/settings/RemoteInputSection.tsx
Normal file
121
components/settings/RemoteInputSection.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
import { View, Switch, StyleSheet } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
|
||||
interface RemoteInputSectionProps {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged }) => {
|
||||
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
|
||||
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
setRemoteInputEnabled(enabled);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingInfo}>
|
||||
<ThemedText style={styles.settingName}>启用远程输入</ThemedText>
|
||||
</View>
|
||||
<Switch
|
||||
value={remoteInputEnabled}
|
||||
onValueChange={handleToggle}
|
||||
trackColor={{ false: "#767577", true: "#007AFF" }}
|
||||
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{remoteInputEnabled && (
|
||||
<View style={styles.statusContainer}>
|
||||
<View style={styles.statusItem}>
|
||||
<ThemedText style={styles.statusLabel}>服务状态:</ThemedText>
|
||||
<ThemedText style={[styles.statusValue, { color: isServerRunning ? "#00FF00" : "#FF6B6B" }]}>
|
||||
{isServerRunning ? "运行中" : "已停止"}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{serverUrl && (
|
||||
<View style={styles.statusItem}>
|
||||
<ThemedText style={styles.statusLabel}>访问地址:</ThemedText>
|
||||
<ThemedText style={styles.statusValue}>{serverUrl}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.statusItem}>
|
||||
<ThemedText style={styles.statusLabel}>错误:</ThemedText>
|
||||
<ThemedText style={[styles.statusValue, { color: "#FF6B6B" }]}>{error}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#333",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
},
|
||||
settingInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
settingName: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
color: "#888",
|
||||
},
|
||||
statusContainer: {
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
backgroundColor: "#2a2a2c",
|
||||
borderRadius: 8,
|
||||
},
|
||||
statusItem: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 14,
|
||||
color: "#ccc",
|
||||
minWidth: 80,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
131
docs/SETTINGS_REFACTOR_PLAN.md
Normal file
131
docs/SETTINGS_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 设置页面重构方案
|
||||
|
||||
## 目标
|
||||
1. 将设置从弹窗模式改为独立页面
|
||||
2. 新增直播源配置功能
|
||||
3. 新增远程输入开关配置
|
||||
4. 新增播放源启用配置
|
||||
|
||||
## 现有架构分析
|
||||
|
||||
### 当前设置相关文件:
|
||||
- `stores/settingsStore.ts` - 设置状态管理,目前只有API地址配置
|
||||
- `components/SettingsModal.tsx` - 设置弹窗组件
|
||||
- `stores/remoteControlStore.ts` - 远程控制状态管理
|
||||
|
||||
### 现有功能:
|
||||
- API基础地址配置
|
||||
- 远程控制服务器(但未集成到设置中)
|
||||
|
||||
## 重构方案
|
||||
|
||||
### 1. 创建独立设置页面
|
||||
- 新建 `app/settings.tsx` 页面
|
||||
- 使用 Expo Router 的文件路由系统
|
||||
- 删除现有的 `SettingsModal.tsx` 组件
|
||||
|
||||
### 2. 扩展设置Store
|
||||
在 `settingsStore.ts` 中新增以下配置项:
|
||||
```typescript
|
||||
interface SettingsState {
|
||||
// 现有配置
|
||||
apiBaseUrl: string;
|
||||
|
||||
// 新增配置项
|
||||
liveStreamSources: LiveStreamSource[]; // 直播源配置
|
||||
remoteInputEnabled: boolean; // 远程输入开关
|
||||
playbackSourceConfig: PlaybackSourceConfig; // 播放源配置
|
||||
}
|
||||
|
||||
interface LiveStreamSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PlaybackSourceConfig {
|
||||
primarySource: string;
|
||||
fallbackSources: string[];
|
||||
enabledSources: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 设置页面UI结构
|
||||
```
|
||||
设置页面 (app/settings.tsx)
|
||||
├── API 配置区域
|
||||
│ └── API 基础地址输入框
|
||||
├── 直播源配置区域
|
||||
│ ├── 直播源列表
|
||||
│ ├── 添加直播源按钮
|
||||
│ └── 编辑/删除直播源功能
|
||||
├── 远程输入配置区域
|
||||
│ └── 远程输入开关
|
||||
└── 播放源配置区域
|
||||
├── 主播放源选择
|
||||
├── 备用播放源配置
|
||||
└── 启用的播放源选择
|
||||
```
|
||||
|
||||
### 4. 组件设计
|
||||
- 使用 TV 适配的组件和样式
|
||||
- 实现焦点管理和遥控器导航
|
||||
- 遵循现有的设计规范(ThemedView, ThemedText, StyledButton)
|
||||
|
||||
### 5. 导航集成
|
||||
- 在主页面添加设置入口
|
||||
- 使用 Expo Router 的 router.push('/settings') 进行导航
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. **扩展 settingsStore.ts**
|
||||
- 添加新的状态接口
|
||||
- 实现新配置项的增删改查方法
|
||||
- 集成本地存储
|
||||
|
||||
2. **创建设置页面**
|
||||
- 新建 `app/settings.tsx`
|
||||
- 实现基础页面结构和导航
|
||||
|
||||
3. **实现配置组件**
|
||||
- API 配置组件(复用现有逻辑)
|
||||
- 直播源配置组件
|
||||
- 远程输入开关组件
|
||||
- 播放源配置组件
|
||||
|
||||
4. **集成远程控制**
|
||||
- 将远程控制功能集成到设置页面
|
||||
- 统一管理所有设置项
|
||||
|
||||
5. **更新导航**
|
||||
- 在主页面添加设置入口
|
||||
- 移除现有的设置弹窗触发逻辑
|
||||
|
||||
6. **测试验证**
|
||||
- 测试所有配置项的保存和加载
|
||||
- 验证TV平台的交互体验
|
||||
- 确保配置项生效
|
||||
|
||||
## 技术考虑
|
||||
|
||||
### TV平台适配
|
||||
- 使用 `useTVRemoteHandler` 处理遥控器事件
|
||||
- 实现合适的焦点管理
|
||||
- 确保所有交互元素可通过遥控器操作
|
||||
|
||||
### 数据持久化
|
||||
- 使用现有的 `SettingsManager` 进行本地存储
|
||||
- 确保新配置项能正确保存和恢复
|
||||
|
||||
### 向后兼容
|
||||
- 保持现有API配置功能不变
|
||||
- 为新配置项提供默认值
|
||||
- 处理旧版本设置数据的迁移
|
||||
|
||||
## 预期收益
|
||||
|
||||
1. **更好的用户体验**:独立页面提供更多空间展示配置选项
|
||||
2. **功能扩展性**:为未来添加更多配置项提供良好基础
|
||||
3. **代码组织**:将设置相关功能集中管理
|
||||
4. **TV平台适配**:更好的遥控器交互体验
|
||||
@@ -119,11 +119,6 @@ class RemoteControlService {
|
||||
public async startServer(): Promise<string> {
|
||||
console.log('[RemoteControl] Attempting to start server...');
|
||||
|
||||
if (this.httpServer.getIsRunning()) {
|
||||
console.log('[RemoteControl] Server is already running.');
|
||||
throw new Error('Server is already running.');
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await this.httpServer.start();
|
||||
console.log(`[RemoteControl] Server started successfully at: ${url}`);
|
||||
|
||||
@@ -96,10 +96,6 @@ class TCPHttpServer {
|
||||
}
|
||||
|
||||
public async start(): Promise<string> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Server is already running');
|
||||
}
|
||||
|
||||
const netState = await NetInfo.fetch();
|
||||
let ipAddress: string | null = null;
|
||||
|
||||
@@ -111,6 +107,11 @@ class TCPHttpServer {
|
||||
throw new Error('无法获取IP地址,请确认设备已连接到WiFi或以太网。');
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
console.log('[TCPHttpServer] Server is already running.');
|
||||
return `http://${ipAddress}:${PORT}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {
|
||||
|
||||
@@ -3,11 +3,33 @@ import { SettingsManager } from '@/services/storage';
|
||||
import { api } from '@/services/api';
|
||||
import useHomeStore from './homeStore';
|
||||
|
||||
export interface LiveStreamSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackSourceConfig {
|
||||
primarySource: string;
|
||||
fallbackSources: string[];
|
||||
enabledSources: string[];
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
apiBaseUrl: string;
|
||||
liveStreamSources: LiveStreamSource[];
|
||||
remoteInputEnabled: boolean;
|
||||
playbackSourceConfig: PlaybackSourceConfig;
|
||||
isModalVisible: boolean;
|
||||
loadSettings: () => Promise<void>;
|
||||
setApiBaseUrl: (url: string) => void;
|
||||
setLiveStreamSources: (sources: LiveStreamSource[]) => void;
|
||||
addLiveStreamSource: (source: Omit<LiveStreamSource, 'id'>) => void;
|
||||
removeLiveStreamSource: (id: string) => void;
|
||||
updateLiveStreamSource: (id: string, updates: Partial<LiveStreamSource>) => void;
|
||||
setRemoteInputEnabled: (enabled: boolean) => void;
|
||||
setPlaybackSourceConfig: (config: PlaybackSourceConfig) => void;
|
||||
saveSettings: () => Promise<void>;
|
||||
showModal: () => void;
|
||||
hideModal: () => void;
|
||||
@@ -15,16 +37,60 @@ interface SettingsState {
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
apiBaseUrl: 'https://orion-tv.edu.deal',
|
||||
liveStreamSources: [],
|
||||
remoteInputEnabled: false,
|
||||
playbackSourceConfig: {
|
||||
primarySource: 'default',
|
||||
fallbackSources: [],
|
||||
enabledSources: ['default'],
|
||||
},
|
||||
isModalVisible: false,
|
||||
loadSettings: async () => {
|
||||
const settings = await SettingsManager.get();
|
||||
set({ apiBaseUrl: settings.apiBaseUrl });
|
||||
set({
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
liveStreamSources: settings.liveStreamSources || [],
|
||||
remoteInputEnabled: settings.remoteInputEnabled || false,
|
||||
playbackSourceConfig: settings.playbackSourceConfig || {
|
||||
primarySource: 'default',
|
||||
fallbackSources: [],
|
||||
enabledSources: ['default'],
|
||||
},
|
||||
});
|
||||
api.setBaseUrl(settings.apiBaseUrl);
|
||||
},
|
||||
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
|
||||
setLiveStreamSources: (sources) => set({ liveStreamSources: sources }),
|
||||
addLiveStreamSource: (source) => {
|
||||
const { liveStreamSources } = get();
|
||||
const newSource = {
|
||||
...source,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
set({ liveStreamSources: [...liveStreamSources, newSource] });
|
||||
},
|
||||
removeLiveStreamSource: (id) => {
|
||||
const { liveStreamSources } = get();
|
||||
set({ liveStreamSources: liveStreamSources.filter(s => s.id !== id) });
|
||||
},
|
||||
updateLiveStreamSource: (id, updates) => {
|
||||
const { liveStreamSources } = get();
|
||||
set({
|
||||
liveStreamSources: liveStreamSources.map(s =>
|
||||
s.id === id ? { ...s, ...updates } : s
|
||||
)
|
||||
});
|
||||
},
|
||||
setRemoteInputEnabled: (enabled) => set({ remoteInputEnabled: enabled }),
|
||||
setPlaybackSourceConfig: (config) => set({ playbackSourceConfig: config }),
|
||||
saveSettings: async () => {
|
||||
const { apiBaseUrl } = get();
|
||||
await SettingsManager.save({ apiBaseUrl });
|
||||
const { apiBaseUrl, liveStreamSources, remoteInputEnabled, playbackSourceConfig } = get();
|
||||
await SettingsManager.save({
|
||||
apiBaseUrl,
|
||||
liveStreamSources,
|
||||
remoteInputEnabled,
|
||||
playbackSourceConfig,
|
||||
});
|
||||
api.setBaseUrl(apiBaseUrl);
|
||||
set({ isModalVisible: false });
|
||||
useHomeStore.getState().fetchInitialData();
|
||||
|
||||
Reference in New Issue
Block a user