diff --git a/app/detail.tsx b/app/detail.tsx index ed9915b..aad0c49 100644 --- a/app/detail.tsx +++ b/app/detail.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from 'react-native'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { ThemedView } from '@/components/ThemedView'; -import { ThemedText } from '@/components/ThemedText'; -import { api, SearchResult } from '@/services/api'; -import { getResolutionFromM3U8 } from '@/services/m3u8'; -import { DetailButton } from '@/components/DetailButton'; +import React, { useEffect, useState, useRef } from "react"; +import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { ThemedView } from "@/components/ThemedView"; +import { ThemedText } from "@/components/ThemedText"; +import { api, SearchResult } from "@/services/api"; +import { getResolutionFromM3U8 } from "@/services/m3u8"; +import { StyledButton } from "@/components/StyledButton"; export default function DetailScreen() { const { source, q } = useLocalSearchParams(); @@ -24,7 +24,7 @@ export default function DetailScreen() { controllerRef.current = new AbortController(); const signal = controllerRef.current.signal; - if (typeof q === 'string') { + if (typeof q === "string") { const fetchDetailData = async () => { setLoading(true); setSearchResults([]); @@ -35,15 +35,15 @@ export default function DetailScreen() { try { const resources = await api.getResources(signal); if (!resources || resources.length === 0) { - setError('没有可用的播放源'); + setError("没有可用的播放源"); setLoading(false); return; } let foundFirstResult = false; // Prioritize source from params if available - if (typeof source === 'string') { - const index = resources.findIndex(r => r.key === source); + if (typeof source === "string") { + const index = resources.findIndex((r) => r.key === source); if (index > 0) { resources.unshift(resources.splice(index, 1)[0]); } @@ -61,14 +61,14 @@ export default function DetailScreen() { resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal); } } catch (e) { - if ((e as Error).name !== 'AbortError') { + if ((e as Error).name !== "AbortError") { console.error(`Failed to get resolution for ${resource.name}`, e); } } const resultWithResolution = { ...searchResult, resolution }; - setSearchResults(prev => [...prev, resultWithResolution]); + setSearchResults((prev) => [...prev, resultWithResolution]); if (!foundFirstResult) { setDetail(resultWithResolution); @@ -77,19 +77,19 @@ export default function DetailScreen() { } } } catch (e) { - if ((e as Error).name !== 'AbortError') { + if ((e as Error).name !== "AbortError") { console.error(`Error searching in resource ${resource.name}:`, e); } } } if (!foundFirstResult) { - setError('未找到播放源'); + setError("未找到播放源"); setLoading(false); } } catch (e) { - if ((e as Error).name !== 'AbortError') { - setError(e instanceof Error ? e.message : '获取资源列表失败'); + if ((e as Error).name !== "AbortError") { + setError(e instanceof Error ? e.message : "获取资源列表失败"); setLoading(false); } } finally { @@ -108,7 +108,7 @@ export default function DetailScreen() { if (!detail) return; controllerRef.current?.abort(); // Cancel any ongoing fetches router.push({ - pathname: '/play', + pathname: "/play", params: { source: detail.source, id: detail.id.toString(), @@ -171,26 +171,27 @@ export default function DetailScreen() { {searchResults.map((item, index) => ( - setDetail(item)} hasTVPreferredFocus={index === 0} - style={[styles.sourceButton, detail?.source === item.source && styles.sourceButtonSelected]} + isSelected={detail?.source === item.source} + style={styles.sourceButton} > {item.source_name} {item.episodes.length > 1 && ( - {item.episodes.length > 99 ? '99+' : `${item.episodes.length}`} + {item.episodes.length > 99 ? "99+" : `${item.episodes.length}`} )} {item.resolution && ( - + {item.resolution} )} - + ))} @@ -198,9 +199,13 @@ export default function DetailScreen() { 播放列表 {detail.episodes.map((episode, index) => ( - handlePlay(episode, index)}> - {`第 ${index + 1} 集`} - + handlePlay(episode, index)} + text={`第 ${index + 1} 集`} + textStyle={styles.episodeButtonText} + /> ))} @@ -212,9 +217,9 @@ export default function DetailScreen() { const styles = StyleSheet.create({ container: { flex: 1 }, - centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + centered: { flex: 1, justifyContent: "center", alignItems: "center" }, topContainer: { - flexDirection: 'row', + flexDirection: "row", padding: 20, }, poster: { @@ -225,20 +230,20 @@ const styles = StyleSheet.create({ infoContainer: { flex: 1, marginLeft: 20, - justifyContent: 'flex-start', + justifyContent: "flex-start", }, title: { fontSize: 28, - fontWeight: 'bold', + fontWeight: "bold", marginBottom: 10, paddingTop: 20, }, metaContainer: { - flexDirection: 'row', + flexDirection: "row", marginBottom: 10, }, metaText: { - color: '#aaa', + color: "#aaa", marginRight: 10, fontSize: 14, }, @@ -247,7 +252,7 @@ const styles = StyleSheet.create({ }, description: { fontSize: 14, - color: '#ccc', + color: "#ccc", lineHeight: 22, }, bottomContainer: { @@ -257,70 +262,53 @@ const styles = StyleSheet.create({ marginTop: 20, }, sourcesTitleContainer: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", marginBottom: 10, }, sourcesTitle: { fontSize: 20, - fontWeight: 'bold', + fontWeight: "bold", }, sourceList: { - flexDirection: 'row', - flexWrap: 'wrap', + flexDirection: "row", + flexWrap: "wrap", }, sourceButton: { - backgroundColor: '#333', - paddingHorizontal: 15, - paddingVertical: 10, - borderRadius: 8, margin: 5, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 2, - borderColor: 'transparent', - }, - sourceButtonSelected: { - backgroundColor: '#007bff', }, sourceButtonText: { - color: 'white', + color: "white", fontSize: 16, }, badge: { - backgroundColor: 'red', + backgroundColor: "red", borderRadius: 10, paddingHorizontal: 6, paddingVertical: 2, marginLeft: 8, }, badgeText: { - color: 'white', + color: "white", fontSize: 12, - fontWeight: 'bold', + fontWeight: "bold", }, episodesContainer: { marginTop: 20, }, episodesTitle: { fontSize: 20, - fontWeight: 'bold', + fontWeight: "bold", marginBottom: 10, }, episodeList: { - flexDirection: 'row', - flexWrap: 'wrap', + flexDirection: "row", + flexWrap: "wrap", }, episodeButton: { - backgroundColor: '#333', - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 8, margin: 5, - borderWidth: 2, - borderColor: 'transparent', }, episodeButtonText: { - color: 'white', + color: "white", }, }); diff --git a/app/index.tsx b/app/index.tsx index 7b1d9a1..c177471 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -8,6 +8,7 @@ import { useFocusEffect, useRouter } from "expo-router"; import { useColorScheme } from "react-native"; import { Search, Settings } from "lucide-react-native"; import { SettingsModal } from "@/components/SettingsModal"; +import { StyledButton } from "@/components/StyledButton"; import useHomeStore, { RowItem, Category } from "@/stores/homeStore"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -53,16 +54,14 @@ export default function HomeScreen() { const renderCategory = ({ item }: { item: Category }) => { const isSelected = selectedCategory?.title === item.title; return ( - [ - styles.categoryButton, - isSelected && styles.categoryButtonSelected, - focused && styles.categoryButtonFocused, - ]} + handleCategorySelect(item)} - > - {item.title} - + isSelected={isSelected} + variant="primary" + style={styles.categoryButton} + textStyle={styles.categoryText} + /> ); }; @@ -97,18 +96,16 @@ export default function HomeScreen() { 首页 - [styles.searchButton, focused && styles.searchButtonFocused]} + router.push({ pathname: "/search" })} + variant="ghost" > - - [styles.searchButton, focused && styles.searchButtonFocused]} - onPress={showSettingsModal} - > + + - + @@ -191,10 +188,6 @@ const styles = StyleSheet.create({ borderRadius: 30, marginLeft: 10, }, - searchButtonFocused: { - backgroundColor: "#007AFF", - transform: [{ scale: 1.1 }], - }, // Category Selector categoryContainer: { paddingBottom: 10, @@ -208,20 +201,10 @@ const styles = StyleSheet.create({ borderRadius: 8, marginHorizontal: 5, }, - categoryButtonSelected: { - backgroundColor: "#007AFF", // A bright blue for selected state - }, - categoryButtonFocused: { - backgroundColor: "#0056b3", // A darker blue for focused state - elevation: 5, - }, categoryText: { fontSize: 16, fontWeight: "500", }, - categoryTextSelected: { - color: "#FFFFFF", - }, // Content Grid listContent: { paddingHorizontal: 16, diff --git a/app/search.tsx b/app/search.tsx index bf0528c..5cd00bc 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -1,23 +1,14 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { - View, - TextInput, - StyleSheet, - FlatList, - ActivityIndicator, - Pressable, - Text, - Keyboard, - useColorScheme, -} from 'react-native'; -import { ThemedView } from '@/components/ThemedView'; -import { ThemedText } from '@/components/ThemedText'; -import VideoCard from '@/components/VideoCard.tv'; -import { api, SearchResult } from '@/services/api'; -import { Search } from 'lucide-react-native'; +import React, { useState, useRef, useEffect } from "react"; +import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Text, Keyboard, useColorScheme } from "react-native"; +import { ThemedView } from "@/components/ThemedView"; +import { ThemedText } from "@/components/ThemedText"; +import VideoCard from "@/components/VideoCard.tv"; +import { api, SearchResult } from "@/services/api"; +import { Search } from "lucide-react-native"; +import { StyledButton } from "@/components/StyledButton"; export default function SearchScreen() { - const [keyword, setKeyword] = useState(''); + const [keyword, setKeyword] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -46,11 +37,11 @@ export default function SearchScreen() { if (response.results.length > 0) { setResults(response.results); } else { - setError('没有找到相关内容'); + setError("没有找到相关内容"); } } catch (err) { - setError('搜索失败,请稍后重试。'); - console.error('Search failed:', err); + setError("搜索失败,请稍后重试。"); + console.error("Search failed:", err); } finally { setLoading(false); } @@ -76,13 +67,13 @@ export default function SearchScreen() { style={[ styles.input, { - backgroundColor: colorScheme === 'dark' ? '#2c2c2e' : '#f0f0f0', - color: colorScheme === 'dark' ? 'white' : 'black', - borderColor: isInputFocused ? '#007bff' : 'transparent', + backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0", + color: colorScheme === "dark" ? "white" : "black", + borderColor: isInputFocused ? "#007bff" : "transparent", }, ]} placeholder="搜索电影、剧集..." - placeholderTextColor={colorScheme === 'dark' ? '#888' : '#555'} + placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"} value={keyword} onChangeText={setKeyword} onFocus={() => setIsInputFocused(true)} @@ -90,18 +81,9 @@ export default function SearchScreen() { onSubmitEditing={handleSearch} // Allow searching with remote 'enter' button returnKeyType="search" /> - [ - styles.searchButton, - { - backgroundColor: colorScheme === 'dark' ? '#3a3a3c' : '#e0e0e0', - }, - focused && styles.focusedButton, - ]} - onPress={handleSearch} - > - - + + + {loading ? ( @@ -136,39 +118,35 @@ const styles = StyleSheet.create({ paddingTop: 50, }, searchContainer: { - flexDirection: 'row', + flexDirection: "row", paddingHorizontal: 20, marginBottom: 20, - alignItems: 'center', + alignItems: "center", }, input: { flex: 1, height: 50, - backgroundColor: '#2c2c2e', // Default for dark mode, overridden inline + backgroundColor: "#2c2c2e", // Default for dark mode, overridden inline borderRadius: 8, paddingHorizontal: 15, - color: 'white', // Default for dark mode, overridden inline + color: "white", // Default for dark mode, overridden inline fontSize: 18, marginRight: 10, borderWidth: 2, - borderColor: 'transparent', // Default, overridden for focus + borderColor: "transparent", // Default, overridden for focus }, searchButton: { padding: 12, // backgroundColor is now set dynamically borderRadius: 8, }, - focusedButton: { - backgroundColor: '#007bff', - transform: [{ scale: 1.1 }], - }, centerContainer: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", }, errorText: { - color: 'red', + color: "red", }, listContent: { paddingHorizontal: 10, diff --git a/components/DetailButton.tsx b/components/DetailButton.tsx deleted file mode 100644 index b7c7c6b..0000000 --- a/components/DetailButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { - Pressable, - StyleSheet, - StyleProp, - ViewStyle, - PressableProps, -} from "react-native"; - -interface DetailButtonProps extends PressableProps { - children: React.ReactNode; - style?: StyleProp; -} - -export const DetailButton: React.FC = ({ - children, - style, - ...rest -}) => { - return ( - [ - styles.button, - style, - focused && styles.buttonFocused, - ]} - {...rest} - > - {children} - - ); -}; - -const styles = StyleSheet.create({ - button: { - backgroundColor: "#333", - paddingHorizontal: 15, - paddingVertical: 10, - borderRadius: 8, - margin: 5, - borderWidth: 2, - borderColor: "transparent", - flexDirection: "row", - alignItems: "center", - }, - buttonFocused: { - backgroundColor: "#0056b3", - borderColor: "#fff", - elevation: 5, - shadowColor: "#fff", - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 1, - shadowRadius: 15, - }, -}); diff --git a/components/EpisodeSelectionModal.tsx b/components/EpisodeSelectionModal.tsx index b20cee3..5c81b83 100644 --- a/components/EpisodeSelectionModal.tsx +++ b/components/EpisodeSelectionModal.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { View, Text, StyleSheet, Modal, FlatList, Pressable, TouchableOpacity } from 'react-native'; - -import usePlayerStore from '@/stores/playerStore'; -import { useState } from 'react'; +import React from "react"; +import { View, Text, StyleSheet, Modal, FlatList, Pressable } from "react-native"; +import { StyledButton } from "./StyledButton"; +import usePlayerStore from "@/stores/playerStore"; +import { useState } from "react"; interface Episode { title?: string; @@ -35,21 +35,18 @@ export const EpisodeSelectionModal: React.FC = () => {episodes.length > episodeGroupSize && ( {Array.from({ length: Math.ceil(episodes.length / episodeGroupSize) }, (_, groupIndex) => ( - setSelectedEpisodeGroup(groupIndex)} - > - - {`${groupIndex * episodeGroupSize + 1}-${Math.min( - (groupIndex + 1) * episodeGroupSize, - episodes.length - )}`} - - + isSelected={selectedEpisodeGroup === groupIndex} + style={styles.episodeGroupButton} + textStyle={styles.episodeGroupButtonText} + variant="primary" + /> ))} )} @@ -63,24 +60,19 @@ export const EpisodeSelectionModal: React.FC = () => renderItem={({ item, index }) => { const absoluteIndex = selectedEpisodeGroup * episodeGroupSize + index; return ( - [ - styles.episodeItem, - currentEpisodeIndex === absoluteIndex && styles.episodeItemSelected, - focused && styles.focusedButton, - ]} + onSelectEpisode(absoluteIndex)} + isSelected={currentEpisodeIndex === absoluteIndex} hasTVPreferredFocus={currentEpisodeIndex === absoluteIndex} - > - {item.title || `第 ${absoluteIndex + 1} 集`} - + style={styles.episodeItem} + textStyle={styles.episodeItemText} + /> ); }} /> - [styles.closeButton, focused && styles.focusedButton]} onPress={onClose}> - 关闭 - + @@ -90,69 +82,49 @@ export const EpisodeSelectionModal: React.FC = () => const styles = StyleSheet.create({ modalContainer: { flex: 1, - flexDirection: 'row', - justifyContent: 'flex-end', - backgroundColor: 'transparent', + flexDirection: "row", + justifyContent: "flex-end", + backgroundColor: "transparent", }, modalContent: { width: 400, - height: '100%', - backgroundColor: 'rgba(0, 0, 0, 0.85)', + height: "100%", + backgroundColor: "rgba(0, 0, 0, 0.85)", padding: 20, }, modalTitle: { - color: 'white', + color: "white", marginBottom: 20, - textAlign: 'center', + textAlign: "center", fontSize: 18, - fontWeight: 'bold', + fontWeight: "bold", }, episodeItem: { - backgroundColor: '#333', paddingVertical: 12, - borderRadius: 8, margin: 4, flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - episodeItemSelected: { - backgroundColor: '#007bff', }, episodeItemText: { - color: 'white', fontSize: 14, }, episodeGroupContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'center', + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "center", marginBottom: 15, paddingHorizontal: 10, }, episodeGroupButton: { - backgroundColor: '#444', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 15, margin: 5, }, - episodeGroupButtonSelected: { - backgroundColor: '#007bff', - }, episodeGroupButtonText: { - color: 'white', fontSize: 12, }, closeButton: { - backgroundColor: '#333', padding: 15, - borderRadius: 8, - alignItems: 'center', marginTop: 20, }, - focusedButton: { - backgroundColor: 'rgba(119, 119, 119, 0.9)', - transform: [{ scale: 1.1 }], - }, }); diff --git a/components/MediaButton.tsx b/components/MediaButton.tsx index 415cb82..6f67c68 100644 --- a/components/MediaButton.tsx +++ b/components/MediaButton.tsx @@ -1,53 +1,16 @@ -import React from "react"; -import { Pressable, StyleSheet, StyleProp, ViewStyle } from "react-native"; +import React, { ComponentProps } from "react"; +import { StyledButton } from "./StyledButton"; +import { StyleSheet } from "react-native"; -interface MediaButtonProps { - onPress: () => void; - children: React.ReactNode; - isDisabled?: boolean; - hasTVPreferredFocus?: boolean; - style?: StyleProp; -} +type StyledButtonProps = ComponentProps; -export const MediaButton: React.FC = ({ - onPress, - children, - isDisabled = false, - hasTVPreferredFocus = false, - style, -}) => { - return ( - [ - styles.mediaControlButton, - focused && styles.focusedButton, - isDisabled && styles.disabledButton, - style, - ]} - > - {children} - - ); -}; +export const MediaButton = (props: StyledButtonProps) => ( + +); const styles = StyleSheet.create({ mediaControlButton: { - backgroundColor: "rgba(51, 51, 51, 0.8)", padding: 12, - borderRadius: 8, - alignItems: "center", - justifyContent: "center", minWidth: 80, - margin: 5, - }, - focusedButton: { - backgroundColor: "rgba(119, 119, 119, 0.9)", - transform: [{ scale: 1.1 }], - }, - disabledButton: { - opacity: 0.5, }, }); diff --git a/components/NextEpisodeOverlay.tsx b/components/NextEpisodeOverlay.tsx index c67b02e..bce49b7 100644 --- a/components/NextEpisodeOverlay.tsx +++ b/components/NextEpisodeOverlay.tsx @@ -1,16 +1,14 @@ import React from "react"; -import { View, StyleSheet, TouchableOpacity } from "react-native"; +import { View, StyleSheet } from "react-native"; import { ThemedText } from "@/components/ThemedText"; +import { StyledButton } from "./StyledButton"; interface NextEpisodeOverlayProps { visible: boolean; onCancel: () => void; } -export const NextEpisodeOverlay: React.FC = ({ - visible, - onCancel, -}) => { +export const NextEpisodeOverlay: React.FC = ({ visible, onCancel }) => { if (!visible) { return null; } @@ -18,12 +16,13 @@ export const NextEpisodeOverlay: React.FC = ({ return ( - - 即将播放下一集... - - - 取消 - + 即将播放下一集... + ); @@ -48,10 +47,8 @@ const styles = StyleSheet.create({ marginBottom: 10, }, nextEpisodeButton: { - backgroundColor: "#333", padding: 8, paddingHorizontal: 15, - borderRadius: 5, }, nextEpisodeButtonText: { fontSize: 14, diff --git a/components/PlayerControls.tsx b/components/PlayerControls.tsx index e327826..c41ce42 100644 --- a/components/PlayerControls.tsx +++ b/components/PlayerControls.tsx @@ -88,7 +88,7 @@ export const PlayerControls: React.FC = ({ showControls, se )} - + diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index ade29eb..3e675fd 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Modal, View, Text, TextInput, StyleSheet, Pressable, useColorScheme } from 'react-native'; -import { ThemedText } from './ThemedText'; -import { ThemedView } from './ThemedView'; -import { useSettingsStore } from '@/stores/settingsStore'; +import React, { useState, useEffect, useRef } from "react"; +import { Modal, View, Text, TextInput, StyleSheet, useColorScheme } 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(); @@ -28,12 +29,12 @@ export const SettingsModal: React.FC = () => { const styles = StyleSheet.create({ modalContainer: { flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.6)', + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.6)", }, modalContent: { - width: '80%', + width: "80%", maxWidth: 500, padding: 24, borderRadius: 12, @@ -41,9 +42,9 @@ export const SettingsModal: React.FC = () => { }, title: { fontSize: 24, - fontWeight: 'bold', + fontWeight: "bold", marginBottom: 20, - textAlign: 'center', + textAlign: "center", }, input: { height: 50, @@ -52,47 +53,28 @@ export const SettingsModal: React.FC = () => { paddingHorizontal: 15, fontSize: 16, marginBottom: 24, - backgroundColor: colorScheme === 'dark' ? '#3a3a3c' : '#f0f0f0', - color: colorScheme === 'dark' ? 'white' : 'black', - borderColor: 'transparent', + backgroundColor: colorScheme === "dark" ? "#3a3a3c" : "#f0f0f0", + color: colorScheme === "dark" ? "white" : "black", + borderColor: "transparent", }, inputFocused: { - borderColor: '#007AFF', - shadowColor: '#007AFF', + borderColor: "#007AFF", + shadowColor: "#007AFF", shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 10, elevation: 5, }, buttonContainer: { - flexDirection: 'row', - justifyContent: 'space-around', + flexDirection: "row", + justifyContent: "space-around", }, button: { flex: 1, - paddingVertical: 12, - borderRadius: 8, - alignItems: 'center', marginHorizontal: 8, }, - buttonSave: { - backgroundColor: '#007AFF', - }, - buttonCancel: { - backgroundColor: colorScheme === 'dark' ? '#444' : '#ccc', - }, buttonText: { - color: 'white', fontSize: 18, - fontWeight: '500', - }, - focusedButton: { - transform: [{ scale: 1.05 }], - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 5, - elevation: 8, }, }); @@ -107,25 +89,27 @@ export const SettingsModal: React.FC = () => { value={apiBaseUrl} onChangeText={setApiBaseUrl} placeholder="输入 API 地址" - placeholderTextColor={colorScheme === 'dark' ? '#888' : '#555'} + placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"} autoCapitalize="none" autoCorrect={false} onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} /> - [styles.button, styles.buttonCancel, focused && styles.focusedButton]} + - 取消 - - [styles.button, styles.buttonSave, focused && styles.focusedButton]} + style={styles.button} + textStyle={styles.buttonText} + variant="default" + /> + - 保存 - + style={styles.button} + textStyle={styles.buttonText} + variant="primary" + /> diff --git a/components/StyledButton.tsx b/components/StyledButton.tsx new file mode 100644 index 0000000..3789956 --- /dev/null +++ b/components/StyledButton.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, useColorScheme } from "react-native"; +import { ThemedText } from "./ThemedText"; +import { Colors } from "@/constants/Colors"; + +interface StyledButtonProps extends PressableProps { + children?: React.ReactNode; + text?: string; + variant?: "default" | "primary" | "ghost"; + isSelected?: boolean; + style?: StyleProp; + textStyle?: StyleProp; +} + +export const StyledButton: React.FC = ({ + children, + text, + variant = "default", + isSelected = false, + style, + textStyle, + ...rest +}) => { + const colorScheme = useColorScheme() ?? "light"; + const colors = Colors[colorScheme]; + + const variantStyles = { + default: StyleSheet.create({ + button: { + backgroundColor: colors.border, + }, + text: { + color: colors.text, + }, + selectedButton: { + backgroundColor: colors.tint, + }, + focusedButton: { + backgroundColor: colors.link, + borderColor: colors.background, + }, + selectedText: { + color: Colors.dark.text, + }, + }), + primary: StyleSheet.create({ + button: { + backgroundColor: "transparent", + }, + text: { + color: colors.text, + }, + focusedButton: { + backgroundColor: colors.link, + borderColor: colors.background, + }, + selectedButton: { + backgroundColor: "rgba(0, 122, 255, 0.3)", + transform: [{ scale: 1.1 }], + }, + selectedText: { + color: colors.link, + }, + }), + ghost: StyleSheet.create({ + button: { + backgroundColor: "transparent", + }, + text: { + color: colors.text, + }, + focusedButton: { + backgroundColor: "rgba(119, 119, 119, 0.9)", + }, + selectedButton: {}, + selectedText: {}, + }), + }; + + const styles = StyleSheet.create({ + button: { + paddingHorizontal: 15, + paddingVertical: 10, + borderRadius: 8, + margin: 5, + borderWidth: 2, + borderColor: "transparent", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + }, + focusedButton: { + backgroundColor: colors.link, + borderColor: colors.background, + transform: [{ scale: 1.1 }], + elevation: 5, + shadowColor: colors.link, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 1, + shadowRadius: 15, + }, + selectedButton: { + backgroundColor: colors.tint, + }, + text: { + fontSize: 16, + fontWeight: "500", + color: colors.text, + }, + selectedText: { + color: Colors.dark.text, + }, + }); + + return ( + [ + styles.button, + variantStyles[variant].button, + isSelected && (variantStyles[variant].selectedButton ?? styles.selectedButton), + focused && (variantStyles[variant].focusedButton ?? styles.focusedButton), + style, + ]} + {...rest} + > + {text ? ( + + {text} + + ) : ( + children + )} + + ); +}; diff --git a/docs/components/StyledButton.md b/docs/components/StyledButton.md new file mode 100644 index 0000000..5b77871 --- /dev/null +++ b/docs/components/StyledButton.md @@ -0,0 +1,77 @@ +# StyledButton 组件设计文档 + +## 1. 目的 + +为了统一整个应用中的按钮样式和行为,减少代码重复,并提高开发效率和一致性,我们设计了一个通用的 `StyledButton` 组件。 + +该组件将取代以下位置的自定义 `Pressable` 和 `TouchableOpacity` 实现: + +- `app/index.tsx` (分类按钮, 头部图标按钮) +- `components/DetailButton.tsx` +- `components/EpisodeSelectionModal.tsx` (剧集分组按钮, 剧集项按钮, 关闭按钮) +- `components/SettingsModal.tsx` (取消和保存按钮) +- `app/search.tsx` (清除按钮) +- `components/MediaButton.tsx` (媒体控制按钮) +- `components/NextEpisodeOverlay.tsx` (取消按钮) + +## 2. API 设计 + +`StyledButton` 组件将基于 React Native 的 `Pressable` 构建,并提供以下 props: + +```typescript +import { PressableProps, StyleProp, ViewStyle, TextStyle } from "react-native"; + +interface StyledButtonProps extends PressableProps { + // 按钮的主要内容,可以是文本或图标等 React 节点 + children?: React.ReactNode; + + // 如果按钮只包含文本,可以使用此 prop 快速设置 + text?: string; + + // 按钮的视觉变体,用于应用不同的预设样式 + // 'default': 默认灰色背景 + // 'primary': 主题色背景,用于关键操作 + // 'ghost': 透明背景,通常用于图标按钮 + variant?: "default" | "primary" | "ghost"; + + // 按钮是否处于选中状态 + isSelected?: boolean; + + // 覆盖容器的样式 + style?: StyleProp; + + // 覆盖文本的样式 (当使用 `text` prop 时生效) + textStyle?: StyleProp; +} +``` + +## 3. 样式和行为 + +### 状态样式: + +- **默认状态 (`default`)**: + - 背景色: `#333` + - 边框: `transparent` +- **聚焦状态 (`focused`)**: + - 背景色: `#0056b3` (深蓝色) + - 边框: `#fff` + - 阴影/光晕效果 + - 轻微放大 (`transform: scale(1.1)`) +- **选中状态 (`isSelected`)**: + - 背景色: `#007AFF` (亮蓝色) +- **主操作 (`primary`)**: + - 默认背景色: `#007AFF` +- **透明背景 (`ghost`)**: + - 默认背景色: `transparent` + +### 结构: + +组件内部将使用 `Pressable` 作为根元素,并根据 `focused` 和 `isSelected` props 动态计算样式。如果 `children` 和 `text` prop 都提供了,`children` 将优先被渲染。 + +## 4. 实现计划 + +1. **创建 `components/StyledButton.tsx` 文件**。 +2. **实现上述 API 和样式逻辑**。 +3. **逐个重构目标文件**,将原有的 `Pressable`/`TouchableOpacity` 替换为新的 `StyledButton` 组件。 +4. **删除旧的、不再需要的样式**。 +5. **测试所有被修改的界面**,确保按钮的功能和视觉效果符合预期。