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(rm:*)",
|
||||||
"Bash(yarn install)",
|
"Bash(yarn install)",
|
||||||
"Bash(yarn lint)",
|
"Bash(yarn lint)",
|
||||||
"Bash(yarn prebuild-tv:*)"
|
"Bash(yarn prebuild-tv:*)",
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(yarn lint:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ export default function RootLayout() {
|
|||||||
const [loaded, error] = useFonts({
|
const [loaded, error] = useFonts({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
});
|
});
|
||||||
const initializeSettings = useSettingsStore((state) => state.loadSettings);
|
const { loadSettings, remoteInputEnabled } = useSettingsStore();
|
||||||
|
const { startServer, stopServer } = useRemoteControlStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeSettings();
|
loadSettings();
|
||||||
}, [initializeSettings]);
|
}, [loadSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded || error) {
|
if (loaded || error) {
|
||||||
@@ -34,21 +35,12 @@ export default function RootLayout() {
|
|||||||
}, [loaded, error]);
|
}, [loaded, error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize the service with store actions to break require cycle
|
if (remoteInputEnabled) {
|
||||||
const { setMessage, hideModal } = useRemoteControlStore.getState();
|
startServer();
|
||||||
remoteControlService.init({
|
} else {
|
||||||
onMessage: setMessage,
|
stopServer();
|
||||||
onHandshake: hideModal,
|
}
|
||||||
});
|
}, [remoteInputEnabled, startServer, stopServer]);
|
||||||
|
|
||||||
// Start the remote control server on app launch
|
|
||||||
useRemoteControlStore.getState().startServer();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Stop the server on app close
|
|
||||||
useRemoteControlStore.getState().stopServer();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!loaded && !error) {
|
if (!loaded && !error) {
|
||||||
return null;
|
return null;
|
||||||
@@ -62,6 +54,7 @@ export default function RootLayout() {
|
|||||||
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
|
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
|
||||||
<Stack.Screen name="search" options={{ headerShown: false }} />
|
<Stack.Screen name="search" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="live" options={{ headerShown: false }} />
|
<Stack.Screen name="live" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import { api } from "@/services/api";
|
|||||||
import VideoCard from "@/components/VideoCard.tv";
|
import VideoCard from "@/components/VideoCard.tv";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { Search, Settings } from "lucide-react-native";
|
import { Search, Settings } from "lucide-react-native";
|
||||||
import { SettingsModal } from "@/components/SettingsModal";
|
|
||||||
import { StyledButton } from "@/components/StyledButton";
|
import { StyledButton } from "@/components/StyledButton";
|
||||||
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
|
||||||
|
|
||||||
const NUM_COLUMNS = 5;
|
const NUM_COLUMNS = 5;
|
||||||
const { width } = Dimensions.get("window");
|
const { width } = Dimensions.get("window");
|
||||||
@@ -34,8 +32,6 @@ export default function HomeScreen() {
|
|||||||
refreshPlayRecords,
|
refreshPlayRecords,
|
||||||
} = useHomeStore();
|
} = useHomeStore();
|
||||||
|
|
||||||
const showSettingsModal = useSettingsStore((state) => state.showModal);
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refreshPlayRecords();
|
refreshPlayRecords();
|
||||||
@@ -52,14 +48,14 @@ export default function HomeScreen() {
|
|||||||
setSelectedTag(defaultTag);
|
setSelectedTag(defaultTag);
|
||||||
selectCategory({ ...selectedCategory, tag: defaultTag });
|
selectCategory({ ...selectedCategory, tag: defaultTag });
|
||||||
}
|
}
|
||||||
}, [selectedCategory, fetchInitialData]);
|
}, [selectedCategory, fetchInitialData, selectCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCategory && selectedCategory.tag) {
|
if (selectedCategory && selectedCategory.tag) {
|
||||||
fetchInitialData();
|
fetchInitialData();
|
||||||
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
|
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
|
||||||
}
|
}
|
||||||
}, [selectedCategory?.tag]);
|
}, [fetchInitialData, selectedCategory, selectedCategory.tag]);
|
||||||
|
|
||||||
const handleCategorySelect = (category: Category) => {
|
const handleCategorySelect = (category: Category) => {
|
||||||
setSelectedTag(null);
|
setSelectedTag(null);
|
||||||
@@ -133,7 +129,7 @@ export default function HomeScreen() {
|
|||||||
>
|
>
|
||||||
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||||
</StyledButton>
|
</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} />
|
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
</View>
|
</View>
|
||||||
@@ -207,7 +203,6 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SettingsModal />
|
|
||||||
</ThemedView>
|
</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> {
|
public async startServer(): Promise<string> {
|
||||||
console.log('[RemoteControl] Attempting to start server...');
|
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 {
|
try {
|
||||||
const url = await this.httpServer.start();
|
const url = await this.httpServer.start();
|
||||||
console.log(`[RemoteControl] Server started successfully at: ${url}`);
|
console.log(`[RemoteControl] Server started successfully at: ${url}`);
|
||||||
|
|||||||
@@ -96,10 +96,6 @@ class TCPHttpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async start(): Promise<string> {
|
public async start(): Promise<string> {
|
||||||
if (this.isRunning) {
|
|
||||||
throw new Error('Server is already running');
|
|
||||||
}
|
|
||||||
|
|
||||||
const netState = await NetInfo.fetch();
|
const netState = await NetInfo.fetch();
|
||||||
let ipAddress: string | null = null;
|
let ipAddress: string | null = null;
|
||||||
|
|
||||||
@@ -111,6 +107,11 @@ class TCPHttpServer {
|
|||||||
throw new Error('无法获取IP地址,请确认设备已连接到WiFi或以太网。');
|
throw new Error('无法获取IP地址,请确认设备已连接到WiFi或以太网。');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.log('[TCPHttpServer] Server is already running.');
|
||||||
|
return `http://${ipAddress}:${PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {
|
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {
|
||||||
|
|||||||
@@ -3,11 +3,33 @@ import { SettingsManager } from '@/services/storage';
|
|||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import useHomeStore from './homeStore';
|
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 {
|
interface SettingsState {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
|
liveStreamSources: LiveStreamSource[];
|
||||||
|
remoteInputEnabled: boolean;
|
||||||
|
playbackSourceConfig: PlaybackSourceConfig;
|
||||||
isModalVisible: boolean;
|
isModalVisible: boolean;
|
||||||
loadSettings: () => Promise<void>;
|
loadSettings: () => Promise<void>;
|
||||||
setApiBaseUrl: (url: string) => 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>;
|
saveSettings: () => Promise<void>;
|
||||||
showModal: () => void;
|
showModal: () => void;
|
||||||
hideModal: () => void;
|
hideModal: () => void;
|
||||||
@@ -15,16 +37,60 @@ interface SettingsState {
|
|||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||||
apiBaseUrl: 'https://orion-tv.edu.deal',
|
apiBaseUrl: 'https://orion-tv.edu.deal',
|
||||||
|
liveStreamSources: [],
|
||||||
|
remoteInputEnabled: false,
|
||||||
|
playbackSourceConfig: {
|
||||||
|
primarySource: 'default',
|
||||||
|
fallbackSources: [],
|
||||||
|
enabledSources: ['default'],
|
||||||
|
},
|
||||||
isModalVisible: false,
|
isModalVisible: false,
|
||||||
loadSettings: async () => {
|
loadSettings: async () => {
|
||||||
const settings = await SettingsManager.get();
|
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);
|
api.setBaseUrl(settings.apiBaseUrl);
|
||||||
},
|
},
|
||||||
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
|
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 () => {
|
saveSettings: async () => {
|
||||||
const { apiBaseUrl } = get();
|
const { apiBaseUrl, liveStreamSources, remoteInputEnabled, playbackSourceConfig } = get();
|
||||||
await SettingsManager.save({ apiBaseUrl });
|
await SettingsManager.save({
|
||||||
|
apiBaseUrl,
|
||||||
|
liveStreamSources,
|
||||||
|
remoteInputEnabled,
|
||||||
|
playbackSourceConfig,
|
||||||
|
});
|
||||||
api.setBaseUrl(apiBaseUrl);
|
api.setBaseUrl(apiBaseUrl);
|
||||||
set({ isModalVisible: false });
|
set({ isModalVisible: false });
|
||||||
useHomeStore.getState().fetchInitialData();
|
useHomeStore.getState().fetchInitialData();
|
||||||
|
|||||||
Reference in New Issue
Block a user