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:
zimplexing
2025-07-11 17:23:36 +08:00
parent fc8da352fb
commit 03d80c42cd
14 changed files with 607 additions and 215 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",

View File

@@ -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,
},
});

View File

@@ -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",
},
});

View File

@@ -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",

View 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%",
},
});

View 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,
},
});

View File

@@ -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[];

View File

@@ -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 }],

View File

@@ -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);

View File

@@ -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 });