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, including live stream source and remote input settings
This commit is contained in:
@@ -6,6 +6,7 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { api, SearchResult } from "@/services/api";
|
||||
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
export default function DetailScreen() {
|
||||
const { source, q } = useLocalSearchParams();
|
||||
@@ -16,6 +17,7 @@ export default function DetailScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const { videoSource } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (controllerRef.current) {
|
||||
@@ -33,13 +35,24 @@ export default function DetailScreen() {
|
||||
setAllSourcesLoaded(false);
|
||||
|
||||
try {
|
||||
const resources = await api.getResources(signal);
|
||||
if (!resources || resources.length === 0) {
|
||||
const allResources = await api.getResources(signal);
|
||||
if (!allResources || allResources.length === 0) {
|
||||
setError("没有可用的播放源");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter resources based on enabled sources in settings
|
||||
const resources = videoSource.enabledAll
|
||||
? allResources
|
||||
: allResources.filter((resource) => videoSource.sources[resource.key]);
|
||||
|
||||
if (!videoSource.enabledAll && resources.length === 0) {
|
||||
setError("请到设置页面启用的播放源");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let foundFirstResult = false;
|
||||
// Prioritize source from params if available
|
||||
if (typeof source === "string") {
|
||||
@@ -102,7 +115,7 @@ export default function DetailScreen() {
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, [q, source]);
|
||||
}, [q, source, videoSource.enabledAll, videoSource.sources]);
|
||||
|
||||
const handlePlay = (episodeName: string, episodeIndex: number) => {
|
||||
if (!detail) return;
|
||||
@@ -131,7 +144,9 @@ export default function DetailScreen() {
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.centered}>
|
||||
<ThemedText type="subtitle">{error}</ThemedText>
|
||||
<ThemedText type="subtitle" style={styles.text}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@@ -222,6 +237,10 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
padding: 20,
|
||||
},
|
||||
text: {
|
||||
padding: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
poster: {
|
||||
width: 200,
|
||||
height: 300,
|
||||
|
||||
@@ -5,10 +5,10 @@ import { fetchAndParseM3u, getPlayableUrl, Channel } from "@/services/m3u";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { AVPlaybackStatus } from "expo-av";
|
||||
|
||||
const M3U_URL = "https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
export default function LiveScreen() {
|
||||
const { m3uUrl } = useSettingsStore();
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [groupedChannels, setGroupedChannels] = useState<Record<string, Channel[]>>({});
|
||||
const [channelGroups, setChannelGroups] = useState<string[]>([]);
|
||||
@@ -24,8 +24,9 @@ export default function LiveScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannels = async () => {
|
||||
if (!m3uUrl) return;
|
||||
setIsLoading(true);
|
||||
const parsedChannels = await fetchAndParseM3u(M3U_URL);
|
||||
const parsedChannels = await fetchAndParseM3u(m3uUrl);
|
||||
setChannels(parsedChannels);
|
||||
|
||||
const groups: Record<string, Channel[]> = parsedChannels.reduce((acc, channel) => {
|
||||
@@ -48,7 +49,7 @@ export default function LiveScreen() {
|
||||
setIsLoading(false);
|
||||
};
|
||||
loadChannels();
|
||||
}, []);
|
||||
}, [m3uUrl]);
|
||||
|
||||
const showChannelTitle = (title: string) => {
|
||||
setChannelTitle(title);
|
||||
|
||||
103
app/settings.tsx
103
app/settings.tsx
@@ -1,19 +1,25 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, StyleSheet, ScrollView, Alert } from "react-native";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
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";
|
||||
import { VideoSourceSection } from "@/components/settings/VideoSourceSection";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { loadSettings, saveSettings } = useSettingsStore();
|
||||
const backgroundColor = useThemeColor({}, "background");
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
|
||||
|
||||
const saveButtonRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -24,7 +30,6 @@ export default function SettingsScreen() {
|
||||
try {
|
||||
await saveSettings();
|
||||
setHasChanges(false);
|
||||
Alert.alert("成功", "设置已保存");
|
||||
} catch {
|
||||
Alert.alert("错误", "保存设置失败");
|
||||
} finally {
|
||||
@@ -36,36 +41,78 @@ export default function SettingsScreen() {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{
|
||||
component: <RemoteInputSection onChanged={markAsChanged} onFocus={() => setCurrentFocusIndex(0)} />,
|
||||
key: "remote",
|
||||
},
|
||||
{
|
||||
component: <APIConfigSection onChanged={markAsChanged} onFocus={() => setCurrentFocusIndex(1)} />,
|
||||
key: "api",
|
||||
},
|
||||
{
|
||||
component: <LiveStreamSection onChanged={markAsChanged} onFocus={() => setCurrentFocusIndex(2)} />,
|
||||
key: "livestream",
|
||||
},
|
||||
{
|
||||
component: <VideoSourceSection onChanged={markAsChanged} onFocus={() => setCurrentFocusIndex(3)} />,
|
||||
key: "playback",
|
||||
},
|
||||
];
|
||||
|
||||
// TV遥控器事件处理
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
if (event.eventType === "down") {
|
||||
const nextIndex = Math.min(currentFocusIndex + 1, sections.length);
|
||||
setCurrentFocusIndex(nextIndex);
|
||||
if (nextIndex === sections.length) {
|
||||
saveButtonRef.current?.focus();
|
||||
}
|
||||
} else if (event.eventType === "up") {
|
||||
const prevIndex = Math.max(currentFocusIndex - 1, 0);
|
||||
setCurrentFocusIndex(prevIndex);
|
||||
}
|
||||
},
|
||||
[currentFocusIndex, sections.length]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.title}>设置</ThemedText>
|
||||
</View>
|
||||
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<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.scrollView}>
|
||||
<FlatList
|
||||
data={sections}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<StyledButton
|
||||
text={isLoading ? "保存中..." : "保存设置"}
|
||||
onPress={handleSave}
|
||||
variant="primary"
|
||||
disabled={!hasChanges || isLoading}
|
||||
style={[styles.saveButton, (!hasChanges || isLoading) && styles.disabledButton]}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
<View style={styles.footer}>
|
||||
<StyledButton
|
||||
text={isLoading ? "保存中..." : "保存设置"}
|
||||
onPress={handleSave}
|
||||
variant="primary"
|
||||
disabled={!hasChanges || isLoading}
|
||||
style={[styles.saveButton, (!hasChanges || isLoading) && styles.disabledButton]}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
padding: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
@@ -85,12 +132,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
paddingTop: 12,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
saveButton: {
|
||||
minHeight: 50,
|
||||
width: 120,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle } from "react-native";
|
||||
import { ThemedText } from "./ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useButtonAnimation } from "@/hooks/useButtonAnimation";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
|
||||
interface StyledButtonProps extends PressableProps {
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -1,52 +1,75 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { View, TextInput, StyleSheet } from "react-native";
|
||||
import { View, TextInput, StyleSheet, Pressable, Animated } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
|
||||
interface APIConfigSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export const APIConfigSection: React.FC<APIConfigSectionProps> = ({ onChanged }) => {
|
||||
export const APIConfigSection: React.FC<APIConfigSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
||||
const { apiBaseUrl, setApiBaseUrl } = useSettingsStore();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setApiBaseUrl(url);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const handleSectionFocus = () => {
|
||||
setIsSectionFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleSectionBlur = () => {
|
||||
setIsSectionFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
// TV遥控器事件处理
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
if (isSectionFocused && event.eventType === "select") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[isSectionFocused]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<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)}
|
||||
/>
|
||||
<Animated.View style={inputAnimationStyle}>
|
||||
<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)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#333",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
|
||||
@@ -1,37 +1,98 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { View, TextInput, StyleSheet, Animated } from 'react-native';
|
||||
import { useTVEventHandler } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { useButtonAnimation } from '@/hooks/useAnimation';
|
||||
|
||||
interface LiveStreamSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export const LiveStreamSection: React.FC<LiveStreamSectionProps> = ({ onChanged }) => {
|
||||
export const LiveStreamSection: React.FC<LiveStreamSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
||||
const { m3uUrl, setM3uUrl } = useSettingsStore();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setM3uUrl(url);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const handleSectionFocus = () => {
|
||||
setIsSectionFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleSectionBlur = () => {
|
||||
setIsSectionFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
if (isSectionFocused && event.eventType === "select") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[isSectionFocused]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>直播源配置</ThemedText>
|
||||
<ThemedText style={styles.placeholder}>直播源配置功能即将上线</ThemedText>
|
||||
</ThemedView>
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<View style={styles.inputContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>直播源地址</ThemedText>
|
||||
<Animated.View style={inputAnimationStyle}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, isInputFocused && styles.inputFocused]}
|
||||
value={m3uUrl}
|
||||
onChangeText={handleUrlChange}
|
||||
placeholder="输入 M3U 直播源地址"
|
||||
placeholderTextColor="#888"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
placeholder: {
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
inputContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -1,36 +1,70 @@
|
||||
import React from "react";
|
||||
import { View, Switch, StyleSheet } from "react-native";
|
||||
import React, { useCallback } from "react";
|
||||
import { View, Switch, StyleSheet, Pressable, Animated } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
|
||||
interface RemoteInputSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged }) => {
|
||||
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
||||
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
|
||||
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const animationStyle = useButtonAnimation(isFocused, 1.2);
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
setRemoteInputEnabled(enabled);
|
||||
onChanged();
|
||||
const handleToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setRemoteInputEnabled(enabled);
|
||||
onChanged();
|
||||
},
|
||||
[setRemoteInputEnabled, onChanged]
|
||||
);
|
||||
|
||||
const handleSectionFocus = () => {
|
||||
setIsFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleSectionBlur = () => {
|
||||
setIsFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
// TV遥控器事件处理
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
if (isFocused && event.eventType === "select") {
|
||||
handleToggle(!remoteInputEnabled);
|
||||
}
|
||||
},
|
||||
[isFocused, remoteInputEnabled, handleToggle]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
<View style={styles.settingItem}>
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<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>
|
||||
<Animated.View style={animationStyle}>
|
||||
<Switch
|
||||
value={remoteInputEnabled}
|
||||
onValueChange={() => {}} // 禁用Switch的直接交互
|
||||
trackColor={{ false: "#767577", true: "#007AFF" }}
|
||||
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{remoteInputEnabled && (
|
||||
<View style={styles.statusContainer}>
|
||||
@@ -56,23 +90,11 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
66
components/settings/SettingsSection.tsx
Normal file
66
components/settings/SettingsSection.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from "react";
|
||||
import { StyleSheet, Pressable } from "react-native";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
|
||||
interface SettingsSectionProps {
|
||||
children: React.ReactNode;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
focusable?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
children,
|
||||
onFocus,
|
||||
onBlur,
|
||||
focusable = false
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
if (!focusable) {
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
{children}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
|
||||
<Pressable
|
||||
style={styles.sectionPressable}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#333",
|
||||
},
|
||||
sectionFocused: {
|
||||
borderColor: "#007AFF",
|
||||
backgroundColor: "#007AFF10",
|
||||
},
|
||||
sectionPressable: {
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
213
components/settings/VideoSourceSection.tsx
Normal file
213
components/settings/VideoSourceSection.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { StyleSheet, View, Switch, ActivityIndicator, FlatList, Pressable, Animated } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
import { api, ApiSite } from "@/services/api";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
interface VideoSourceSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
||||
const [resources, setResources] = useState<ApiSite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||
const { videoSource, setVideoSource } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchResources = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resourcesList = await api.getResources();
|
||||
setResources(resourcesList);
|
||||
|
||||
if (videoSource.enabledAll && resourcesList.length === 0) {
|
||||
const allResourceKeys: { [key: string]: boolean } = {};
|
||||
for (const resource of resourcesList) {
|
||||
allResourceKeys[resource.key] = true;
|
||||
}
|
||||
setVideoSource({
|
||||
enabledAll: true,
|
||||
sources: allResourceKeys,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setError("获取播放源失败");
|
||||
console.error("Failed to fetch resources:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleResourceEnabled = useCallback(
|
||||
(resourceKey: string) => {
|
||||
const isEnabled = videoSource.sources[resourceKey];
|
||||
|
||||
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
|
||||
|
||||
setVideoSource({
|
||||
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
|
||||
sources: newEnabledSources,
|
||||
});
|
||||
|
||||
onChanged();
|
||||
},
|
||||
[videoSource.sources, setVideoSource, onChanged]
|
||||
);
|
||||
|
||||
const handleSectionFocus = () => {
|
||||
setIsSectionFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleSectionBlur = () => {
|
||||
setIsSectionFocused(false);
|
||||
setFocusedIndex(null);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
// TV遥控器事件处理
|
||||
const handleTVEvent = useCallback(
|
||||
(event: any) => {
|
||||
if (event.eventType === "select") {
|
||||
if (focusedIndex !== null) {
|
||||
const resource = resources[focusedIndex];
|
||||
if (resource) {
|
||||
toggleResourceEnabled(resource.key);
|
||||
}
|
||||
} else if (isSectionFocused) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isSectionFocused, focusedIndex, resources, toggleResourceEnabled]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
const renderResourceItem = ({ item, index }: { item: ApiSite; index: number }) => {
|
||||
const isEnabled = videoSource.enabledAll || videoSource.sources[item.key];
|
||||
const isFocused = focusedIndex === index;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.resourceItem]}>
|
||||
<Pressable
|
||||
hasTVPreferredFocus={isFocused}
|
||||
style={[styles.resourcePressable, isFocused && styles.resourceFocused]}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onBlur={() => setFocusedIndex(null)}
|
||||
>
|
||||
<ThemedText style={styles.resourceName}>{item.name}</ThemedText>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={() => {}} // 禁用Switch的直接交互
|
||||
trackColor={{ false: "#767577", true: "#007AFF" }}
|
||||
thumbColor={isEnabled ? "#ffffff" : "#f4f3f4"}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<ThemedText style={styles.sectionTitle}>播放源配置</ThemedText>
|
||||
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" />
|
||||
<ThemedText style={styles.loadingText}>加载中...</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <ThemedText style={styles.errorText}>{error}</ThemedText>}
|
||||
|
||||
{!loading && !error && resources.length > 0 && (
|
||||
<FlatList
|
||||
data={resources}
|
||||
renderItem={renderResourceItem}
|
||||
keyExtractor={(item) => item.key}
|
||||
numColumns={3}
|
||||
columnWrapperStyle={styles.row}
|
||||
contentContainerStyle={styles.flatListContainer}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
},
|
||||
loadingText: {
|
||||
marginLeft: 8,
|
||||
color: "#888",
|
||||
},
|
||||
errorText: {
|
||||
color: "#ff4444",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
padding: 16,
|
||||
},
|
||||
flatListContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
row: {
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
resourceItem: {
|
||||
width: "32%",
|
||||
marginHorizontal: 6,
|
||||
marginVertical: 6,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
resourcePressable: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: "#2a2a2a",
|
||||
borderRadius: 8,
|
||||
minHeight: 56,
|
||||
},
|
||||
resourceFocused: {
|
||||
backgroundColor: "#3a3a3c",
|
||||
borderWidth: 2,
|
||||
borderColor: "#007AFF",
|
||||
shadowColor: "#007AFF",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
resourceName: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
@@ -34,7 +34,7 @@ interface SettingsState {
|
||||
// 新增配置项
|
||||
liveStreamSources: LiveStreamSource[]; // 直播源配置
|
||||
remoteInputEnabled: boolean; // 远程输入开关
|
||||
playbackSourceConfig: PlaybackSourceConfig; // 播放源配置
|
||||
videoSourceConfig: VideoSourceConfig; // 播放源配置
|
||||
}
|
||||
|
||||
interface LiveStreamSource {
|
||||
@@ -44,7 +44,7 @@ interface LiveStreamSource {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PlaybackSourceConfig {
|
||||
interface VideoSourceConfig {
|
||||
primarySource: string;
|
||||
fallbackSources: string[];
|
||||
enabledSources: string[];
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Animated } from 'react-native';
|
||||
|
||||
export const useButtonAnimation = (isFocused: boolean) => {
|
||||
export const useButtonAnimation = (isFocused: boolean, size: number = 1.1) => {
|
||||
const scaleValue = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: isFocused ? 1.1 : 1,
|
||||
toValue: isFocused ? size : 1,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [ isFocused, scaleValue]);
|
||||
}, [ isFocused, scaleValue, size]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: scaleValue }],
|
||||
@@ -25,10 +25,15 @@ export interface FavoriteItem {
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "light" | "dark" | "auto";
|
||||
autoPlay: boolean;
|
||||
playbackSpeed: number;
|
||||
apiBaseUrl: string;
|
||||
remoteInputEnabled: boolean;
|
||||
videoSource: {
|
||||
enabledAll: boolean;
|
||||
sources: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
},
|
||||
m3uUrl: string;
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
@@ -178,10 +183,13 @@ export class SearchHistoryManager {
|
||||
export class SettingsManager {
|
||||
static async get(): Promise<AppSettings> {
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "auto",
|
||||
autoPlay: true,
|
||||
playbackSpeed: 1.0,
|
||||
apiBaseUrl: "",
|
||||
apiBaseUrl: "https://orion-tv.edu.deal",
|
||||
remoteInputEnabled: false,
|
||||
videoSource: {
|
||||
enabledAll: true,
|
||||
sources: {},
|
||||
},
|
||||
m3uUrl: "https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u",
|
||||
};
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
|
||||
@@ -3,93 +3,62 @@ 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[];
|
||||
m3uUrl: string;
|
||||
remoteInputEnabled: boolean;
|
||||
playbackSourceConfig: PlaybackSourceConfig;
|
||||
videoSource: {
|
||||
enabledAll: boolean;
|
||||
sources: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
};
|
||||
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;
|
||||
setM3uUrl: (url: string) => void;
|
||||
setRemoteInputEnabled: (enabled: boolean) => void;
|
||||
setPlaybackSourceConfig: (config: PlaybackSourceConfig) => void;
|
||||
saveSettings: () => Promise<void>;
|
||||
setVideoSource: (config: { enabledAll: boolean; sources: {[key: string]: boolean} }) => void;
|
||||
showModal: () => void;
|
||||
hideModal: () => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
apiBaseUrl: 'https://orion-tv.edu.deal',
|
||||
apiBaseUrl: '',
|
||||
m3uUrl: 'https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u',
|
||||
liveStreamSources: [],
|
||||
remoteInputEnabled: false,
|
||||
playbackSourceConfig: {
|
||||
primarySource: 'default',
|
||||
fallbackSources: [],
|
||||
enabledSources: ['default'],
|
||||
},
|
||||
isModalVisible: false,
|
||||
videoSource: {
|
||||
enabledAll: true,
|
||||
sources: {},
|
||||
},
|
||||
loadSettings: async () => {
|
||||
const settings = await SettingsManager.get();
|
||||
set({
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
liveStreamSources: settings.liveStreamSources || [],
|
||||
m3uUrl: settings.m3uUrl,
|
||||
remoteInputEnabled: settings.remoteInputEnabled || false,
|
||||
playbackSourceConfig: settings.playbackSourceConfig || {
|
||||
primarySource: 'default',
|
||||
fallbackSources: [],
|
||||
enabledSources: ['default'],
|
||||
videoSource: settings.videoSource || {
|
||||
enabledAll: true,
|
||||
sources: {},
|
||||
},
|
||||
});
|
||||
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
|
||||
)
|
||||
});
|
||||
},
|
||||
setM3uUrl: (url) => set({ m3uUrl: url }),
|
||||
setRemoteInputEnabled: (enabled) => set({ remoteInputEnabled: enabled }),
|
||||
setPlaybackSourceConfig: (config) => set({ playbackSourceConfig: config }),
|
||||
setVideoSource: (config) => set({ videoSource: config }),
|
||||
saveSettings: async () => {
|
||||
const { apiBaseUrl, liveStreamSources, remoteInputEnabled, playbackSourceConfig } = get();
|
||||
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
|
||||
await SettingsManager.save({
|
||||
apiBaseUrl,
|
||||
liveStreamSources,
|
||||
m3uUrl,
|
||||
remoteInputEnabled,
|
||||
playbackSourceConfig,
|
||||
videoSource,
|
||||
});
|
||||
api.setBaseUrl(apiBaseUrl);
|
||||
set({ isModalVisible: false });
|
||||
|
||||
Reference in New Issue
Block a user