diff --git a/app.json b/app.json index 2f4e3b5..e6337ca 100644 --- a/app.json +++ b/app.json @@ -41,7 +41,9 @@ "expo-router", "react-native-video", "expo-screen-orientation", - "@sentry/react-native/expo" + "@sentry/react-native/expo", + "expo-secure-store", + "expo-image" ], "experiments": { "typedRoutes": true diff --git a/app/downloads.tsx b/app/downloads.tsx index f1620a3..dd4e86d 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -202,12 +202,12 @@ function DownloadRow({ )} diff --git a/app/index.tsx b/app/index.tsx index 1b34de5..d98211f 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -334,6 +334,9 @@ export default function HomeScreen() { } onScroll={onScroll} scrollEventThrottle={16} + windowSize={7} + maxToRenderPerBatch={6} + removeClippedSubviews={true} /> @@ -402,6 +405,9 @@ export default function HomeScreen() { } onScroll={onLiveScroll} scrollEventThrottle={16} + windowSize={7} + maxToRenderPerBatch={6} + removeClippedSubviews={true} /> diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx index 5ba3731..d74001c 100644 --- a/app/live/[roomId].tsx +++ b/app/live/[roomId].tsx @@ -24,7 +24,6 @@ type Tab = "intro" | "danmaku"; export default function LiveDetailScreen() { const { roomId } = useLocalSearchParams<{ roomId: string }>(); - console.log("LiveDetailScreen params:", { roomId }); const router = useRouter(); const theme = useTheme(); const id = parseInt(roomId ?? "0", 10); diff --git a/app/search.tsx b/app/search.tsx index ff20aa2..e076e00 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -7,26 +7,50 @@ import { TouchableOpacity, FlatList, ActivityIndicator, + ScrollView, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { VideoCard } from '../components/VideoCard'; -import { useSearch } from '../hooks/useSearch'; +import { useSearch, SearchSort } from '../hooks/useSearch'; import { useTheme } from '../utils/theme'; import type { VideoItem } from '../services/types'; +const SORT_OPTIONS: { key: SearchSort; label: string }[] = [ + { key: 'default', label: '综合排序' }, + { key: 'pubdate', label: '最新发布' }, + { key: 'view', label: '最多播放' }, +]; + export default function SearchScreen() { const router = useRouter(); - const { keyword, setKeyword, results, loading, hasMore, search, loadMore } = useSearch(); + const { + keyword, setKeyword, + results, loading, hasMore, + search, loadMore, + sort, changeSort, + history, removeFromHistory, clearHistory, + suggestions, + hotSearches, + } = useSearch(); const theme = useTheme(); const inputRef = useRef(null); + const hasResults = results.length > 0; + const hasSearched = hasResults || (loading && results.length === 0); - const handleSearch = useCallback(() => { - if (keyword.trim()) { - search(keyword, true); + const handleSearch = useCallback((kw?: string) => { + const term = (kw ?? keyword).trim(); + if (term) { + if (kw) setKeyword(kw); + search(kw ?? keyword, true); } - }, [keyword, search]); + }, [keyword, search, setKeyword]); + + const handleSuggestionPress = useCallback((value: string) => { + setKeyword(value); + search(value, true); + }, [search, setKeyword]); const renderItem = useCallback( ({ item, index }: { item: VideoItem; index: number }) => { @@ -61,16 +85,37 @@ export default function SearchScreen() { [], ); + // Show pre-search panel (history + hot searches + suggestions) + const showPreSearch = !hasSearched && !loading; + const showSuggestions = suggestions.length > 0 && keyword.trim().length > 0 && !hasResults; + + const ListHeaderComponent = useCallback(() => { + if (!hasResults) return null; + return ( + + {SORT_OPTIONS.map(opt => ( + changeSort(opt.key)} + activeOpacity={0.85} + > + + {opt.label} + + + ))} + + ); + }, [hasResults, sort, changeSort, theme.card]); + const ListEmptyComponent = () => { if (loading) return null; + if (!keyword.trim()) return null; return ( - - {results.length === 0 && keyword.trim() - ? '没有找到相关视频' - : '输入关键词搜索'} - + 没有找到相关视频 ); }; @@ -90,7 +135,7 @@ export default function SearchScreen() { placeholderTextColor="#999" value={keyword} onChangeText={setKeyword} - onSubmitEditing={handleSearch} + onSubmitEditing={() => handleSearch()} returnKeyType="search" autoFocus autoCapitalize="none" @@ -102,29 +147,109 @@ export default function SearchScreen() { )} - + handleSearch()} activeOpacity={0.85}> 搜索 - {/* Results */} - } - ListFooterComponent={ - loading && results.length > 0 ? ( - - + {/* Suggestions dropdown */} + {showSuggestions && ( + + {suggestions.map((s, i) => ( + handleSuggestionPress(s.value)} + activeOpacity={0.85} + > + + {s.value} + + ))} + + )} + + {/* Pre-search: history + hot searches */} + {showPreSearch && !showSuggestions ? ( + + {/* Search history */} + {history.length > 0 && ( + + + 搜索历史 + + + + + + {history.map(h => ( + handleSearch(h)} + onLongPress={() => removeFromHistory(h)} + activeOpacity={0.85} + > + {h} + + ))} + - ) : null - } - keyboardShouldPersistTaps="handled" - /> + )} + + {/* Hot searches */} + {hotSearches.length > 0 && ( + + 热搜榜 + {hotSearches.map((item, idx) => ( + handleSearch(item.keyword)} + activeOpacity={0.85} + > + + {idx + 1} + + + {item.show_name} + + + ))} + + )} + + {history.length === 0 && hotSearches.length === 0 && ( + + + 输入关键词搜索 + + )} + + ) : ( + /* Results list */ + } + ListEmptyComponent={} + ListFooterComponent={ + loading && results.length > 0 ? ( + + + + ) : null + } + keyboardShouldPersistTaps="handled" + /> + )} ); } @@ -163,7 +288,88 @@ const styles = StyleSheet.create({ paddingVertical: 6, }, searchBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' }, - listContent: { paddingTop: 6, paddingBottom: 20 }, + + // Sort bar + sortBar: { + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 12, + }, + sortBtn: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 14, + }, + sortBtnActive: { + backgroundColor: '#00AEEC', + }, + sortBtnText: { + fontSize: 12, + color: '#999', + }, + sortBtnTextActive: { + color: '#fff', + fontWeight: '600', + }, + + // Suggestions + suggestPanel: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#eee', + }, + suggestItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#eee', + }, + suggestIcon: { marginRight: 8 }, + suggestText: { fontSize: 14, flex: 1 }, + + // Pre-search + preSearch: { flex: 1, paddingHorizontal: 16, paddingTop: 12 }, + section: { marginBottom: 20 }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + sectionTitle: { fontSize: 15, fontWeight: '600', color: '#212121', marginBottom: 2 }, + tagWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + tag: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 14, + backgroundColor: '#f0f0f0', + maxWidth: '45%', + }, + tagText: { fontSize: 13, color: '#212121' }, + + // Hot search list + hotItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#eee', + }, + hotIndex: { + width: 22, + fontSize: 14, + fontWeight: '600', + color: '#999', + textAlign: 'center', + marginRight: 10, + }, + hotIndexTop: { color: '#00AEEC' }, + hotText: { fontSize: 14, flex: 1 }, + + // Results + listContent: { paddingTop: 0, paddingBottom: 20 }, row: { flexDirection: 'row', paddingHorizontal: 1, diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 3da2dec..2116256 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -75,7 +75,7 @@ export default function VideoDetailScreen() { useEffect(() => { if (!video?.cid) return; - getDanmaku(video.cid).then(setDanmakus); + getDanmaku(video.cid).then(setDanmakus).catch(() => {}); }, [video?.cid]); useEffect(() => { @@ -197,7 +197,11 @@ export default function VideoDetailScreen() { showsVerticalScrollIndicator={false} ListHeaderComponent={ <> - + router.push(`/creator/${video.owner.mid}` as any)} + > )} - - + 关注 - - + + 查看主页 + + diff --git a/components/CommentItem.tsx b/components/CommentItem.tsx index 3ea0b1e..f53d474 100644 --- a/components/CommentItem.tsx +++ b/components/CommentItem.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, Image, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet } from 'react-native'; +import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import type { Comment } from '../services/types'; import { formatTime } from '../utils/format'; @@ -17,10 +18,10 @@ export function CommentItem({ item }: Props) { {item.member.uname} {item.content.message} - {formatTime(item.ctime)} + {formatTime(item.ctime)} - - {item.like > 0 ? item.like : ''} + + {item.like > 0 ? item.like : ''} diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index 74ed574..164b4cd 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -36,6 +36,24 @@ const FAST_DRIP_INTERVAL = 100; const QUEUE_FAST_THRESHOLD = 50; const SEEK_THRESHOLD = 2; +// ─── Animated.Value 对象池,减少频繁创建/GC ────────────────────────────────── +const animPool: Animated.Value[] = []; +const POOL_MAX = 64; + +function acquireAnim(): Animated.Value { + const v = animPool.pop(); + if (v) { v.setValue(0); return v; } + return new Animated.Value(0); +} + +function releaseAnims(items: DisplayedDanmaku[]) { + for (const item of items) { + if (animPool.length < POOL_MAX) { + animPool.push(item._fadeAnim); + } + } +} + // ─── 舰长等级 ─────────────────────────────────────────────────────────────────── const GUARD_LABELS: Record = { 1: { text: "总督", color: "#E13979" }, @@ -153,7 +171,7 @@ export default function DanmakuList({ if (queueRef.current.length === 0) return; const item = queueRef.current.shift()!; - const fadeAnim = new Animated.Value(0); + const fadeAnim = acquireAnim(); const displayed: DisplayedDanmaku = { ...item, _key: keyCounterRef.current++, @@ -168,9 +186,13 @@ export default function DanmakuList({ setDisplayedItems((prev) => { const next = [...prev, displayed]; - return next.length > maxItems - ? next.slice(-Math.floor(maxItems / 2)) - : next; + if (next.length > maxItems) { + const trimCount = next.length - Math.floor(maxItems / 2); + const trimmed = next.slice(trimCount); + releaseAnims(next.slice(0, trimCount)); + return trimmed; + } + return next; }); if (isAtBottomRef.current) { diff --git a/components/DownloadProgressBtn.tsx b/components/DownloadProgressBtn.tsx index 28ba5dd..f9650aa 100644 --- a/components/DownloadProgressBtn.tsx +++ b/components/DownloadProgressBtn.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useDownloadStore } from '../store/downloadStore'; +import { useTheme } from '../utils/theme'; const SIZE = 32; // 环外径 const RING = 3; // 环宽 @@ -13,6 +14,7 @@ interface Props { } export function DownloadProgressBtn({ onPress }: Props) { + const theme = useTheme(); const tasks = useDownloadStore((s) => s.tasks); const downloading = Object.values(tasks).filter((t) => t.status === 'downloading'); const hasDownloading = downloading.length > 0; @@ -32,7 +34,7 @@ export function DownloadProgressBtn({ onPress }: Props) { ); diff --git a/components/DownloadSheet.tsx b/components/DownloadSheet.tsx index f52a796..558c589 100644 --- a/components/DownloadSheet.tsx +++ b/components/DownloadSheet.tsx @@ -9,6 +9,7 @@ import { } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useDownload } from "../hooks/useDownload"; +import { useTheme } from "../utils/theme"; interface Props { visible: boolean; @@ -30,6 +31,7 @@ export function DownloadSheet({ qualities, }: Props) { const { tasks, startDownload, taskKey } = useDownload(); + const theme = useTheme(); const slideAnim = useRef(new Animated.Value(300)).current; useEffect(() => { @@ -55,21 +57,21 @@ export function DownloadSheet({ onPress={onClose} /> - 下载视频 + 下载视频 - + - + {qualities.map((q) => { const key = taskKey(bvid, q.qn); const task = tasks[key]; return ( - {q.desc} + {q.desc} {!task && ( void; }) { const Video = require("react-native-video").default; + const theme = useTheme(); const [showControls, setShowControls] = useState(true); const [paused, setPaused] = useState(false); @@ -277,12 +279,12 @@ function NativeLivePlayer({ onPress={() => setShowQualityPanel(false)} > {}}> - - 清晰度 + + 清晰度 {qualities.map((q) => ( { onQualityChange?.(q.qn); setShowQualityPanel(false); @@ -291,6 +293,7 @@ function NativeLivePlayer({ diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 8f5f418..079dbfd 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -16,6 +16,7 @@ import QRCode from "react-native-qrcode-svg"; import { Ionicons } from "@expo/vector-icons"; import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili"; import { useAuthStore } from "../store/authStore"; +import { useTheme } from "../utils/theme"; interface Props { visible: boolean; @@ -33,6 +34,7 @@ export function LoginModal({ visible, onClose }: Props) { const pollRef = useRef | null>(null); const login = useAuthStore((s) => s.login); const setProfile = useAuthStore((s) => s.setProfile); + const theme = useTheme(); // sheet 滑入动画 const slideY = useRef(new Animated.Value(300)).current; @@ -69,27 +71,36 @@ export function LoginModal({ visible, onClose }: Props) { useEffect(() => { if (!qrKey || status !== "waiting") return; + let cancelled = false; pollRef.current = setInterval(async () => { - const result = await pollQRCode(qrKey); - if (result.code === 86038) { - setStatus("error"); - clearInterval(pollRef.current!); - } - if (result.code === 86090) setStatus("scanned"); - if (result.code === 0 && result.cookie) { - clearInterval(pollRef.current!); - try { - await login(result.cookie, "", ""); - setStatus("done"); - const info = await getUserInfo(); - setProfile(info.face, info.uname, String(info.mid)); - } catch { + if (cancelled) return; + try { + const result = await pollQRCode(qrKey); + if (cancelled) return; + if (result.code === 86038) { setStatus("error"); + clearInterval(pollRef.current!); } - onClose(); + if (result.code === 86090) setStatus("scanned"); + if (result.code === 0 && result.cookie) { + clearInterval(pollRef.current!); + try { + await login(result.cookie, "", ""); + if (cancelled) return; + setStatus("done"); + const info = await getUserInfo(); + if (!cancelled) setProfile(info.face, info.uname, String(info.mid)); + } catch { + if (!cancelled) setStatus("error"); + } + onClose(); + } + } catch { + // Network error during poll — ignore, will retry next interval } }, 2000); return () => { + cancelled = true; if (pollRef.current) clearInterval(pollRef.current); }; }, [qrKey, status]); @@ -143,8 +154,8 @@ export function LoginModal({ visible, onClose }: Props) { - - 扫码登录 + + 扫码登录 {status === "loading" && ( - + {status === "scanned" ? "扫描成功,请在手机确认" : "使用 B站 APP 扫一扫"} @@ -180,7 +191,7 @@ export function LoginModal({ visible, onClose }: Props) { )} {status === "error" && ( - 二维码已过期,请关闭重试 + 二维码已过期,请关闭重试 )} 关闭 diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 2b7e83e..174a163 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -29,6 +29,7 @@ import type { import { buildDashMpdUri } from "../utils/dash"; import { getVideoShot } from "../services/bilibili"; import DanmakuOverlay from "./DanmakuOverlay"; +import { useTheme } from "../utils/theme"; const BAR_H = 3; // 进度球尺寸 @@ -100,6 +101,7 @@ export const NativeVideoPlayer = forwardRef( ) { const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions(); const VIDEO_H = SCREEN_W * 0.5625; + const theme = useTheme(); const [resolvedUrl, setResolvedUrl] = useState(); const isDash = !!playData?.dash; @@ -569,12 +571,12 @@ export const NativeVideoPlayer = forwardRef( style={styles.modalOverlay} onPress={() => setShowQuality(false)} > - - 选择清晰度 + + 选择清晰度 {qualities.map((q) => ( { setShowQuality(false); onQualityChange(q.qn); @@ -584,6 +586,7 @@ export const NativeVideoPlayer = forwardRef( diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx index ac9c480..2dffc0c 100644 --- a/components/VideoCard.tsx +++ b/components/VideoCard.tsx @@ -2,11 +2,11 @@ import React from "react"; import { View, Text, - Image, TouchableOpacity, StyleSheet, Dimensions, } from "react-native"; +import { Image } from "expo-image"; import { Ionicons } from "@expo/vector-icons"; import type { VideoItem } from "../services/types"; import { formatCount, formatDuration } from "../utils/format"; @@ -35,7 +35,9 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) diff --git a/hooks/useSearch.ts b/hooks/useSearch.ts index 18131eb..3f265fe 100644 --- a/hooks/useSearch.ts +++ b/hooks/useSearch.ts @@ -1,6 +1,25 @@ -import { useState, useCallback, useRef } from 'react'; -import { searchVideos } from '../services/bilibili'; -import type { VideoItem } from '../services/types'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { searchVideos, getSearchSuggest, getHotSearch } from '../services/bilibili'; +import type { VideoItem, SearchSuggestItem, HotSearchItem } from '../services/types'; + +const HISTORY_KEY = 'search_history'; +const MAX_HISTORY = 20; + +export type SearchSort = 'default' | 'pubdate' | 'view'; + +async function loadHistory(): Promise { + try { + const raw = await AsyncStorage.getItem(HISTORY_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +async function saveHistory(history: string[]) { + await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(history)); +} export function useSearch() { const [keyword, setKeyword] = useState(''); @@ -8,18 +27,74 @@ export function useSearch() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); + const [sort, setSort] = useState('default'); + const [history, setHistory] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [hotSearches, setHotSearches] = useState([]); const loadingRef = useRef(false); + const suggestTimer = useRef | null>(null); + const currentSort = useRef('default'); - const search = useCallback(async (kw: string, reset = false) => { + // Load history & hot searches on mount + useEffect(() => { + loadHistory().then(setHistory); + getHotSearch().then(setHotSearches); + }, []); + + // Debounced suggestions + useEffect(() => { + if (suggestTimer.current) clearTimeout(suggestTimer.current); + if (!keyword.trim() || keyword.trim().length < 1) { + setSuggestions([]); + return; + } + suggestTimer.current = setTimeout(async () => { + const items = await getSearchSuggest(keyword.trim()); + setSuggestions(items); + }, 300); + return () => { + if (suggestTimer.current) clearTimeout(suggestTimer.current); + }; + }, [keyword]); + + const addToHistory = useCallback(async (kw: string) => { + const trimmed = kw.trim(); + if (!trimmed) return; + setHistory(prev => { + const filtered = prev.filter(h => h !== trimmed); + const next = [trimmed, ...filtered].slice(0, MAX_HISTORY); + saveHistory(next); + return next; + }); + }, []); + + const removeFromHistory = useCallback(async (kw: string) => { + setHistory(prev => { + const next = prev.filter(h => h !== kw); + saveHistory(next); + return next; + }); + }, []); + + const clearHistory = useCallback(async () => { + setHistory([]); + await AsyncStorage.removeItem(HISTORY_KEY); + }, []); + + const search = useCallback(async (kw: string, reset = false, sortOverride?: SearchSort) => { if (!kw.trim() || loadingRef.current) return; loadingRef.current = true; setLoading(true); + setSuggestions([]); + const activeSort = sortOverride ?? currentSort.current; const currentPage = reset ? 1 : page; + const orderParam = activeSort === 'pubdate' ? 'pubdate' : activeSort === 'view' ? 'click' : ''; try { - const items = await searchVideos(kw, currentPage); + const items = await searchVideos(kw, currentPage, orderParam); if (reset) { setResults(items); setPage(2); + addToHistory(kw); } else { setResults(prev => [...prev, ...items]); setPage(p => p + 1); @@ -31,12 +106,28 @@ export function useSearch() { loadingRef.current = false; setLoading(false); } - }, [page]); + }, [page, addToHistory]); + + const changeSort = useCallback((newSort: SearchSort) => { + setSort(newSort); + currentSort.current = newSort; + if (keyword.trim()) { + search(keyword, true, newSort); + } + }, [keyword, search]); const loadMore = useCallback(() => { if (!keyword.trim() || loadingRef.current || !hasMore) return; search(keyword, false); }, [keyword, hasMore, search]); - return { keyword, setKeyword, results, loading, hasMore, search, loadMore }; + return { + keyword, setKeyword, + results, loading, hasMore, + search, loadMore, + sort, changeSort, + history, removeFromHistory, clearHistory, + suggestions, + hotSearches, + }; } diff --git a/package-lock.json b/package-lock.json index b3afaee..e8ff96c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jkvideo", - "version": "1.0.6", + "version": "1.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jkvideo", - "version": "1.0.6", + "version": "1.0.14", "dependencies": { "@dr.pogodin/react-native-static-server": "^0.26.0", "@expo/vector-icons": "^15.0.2", @@ -19,12 +19,14 @@ "expo-constants": "~55.0.9", "expo-dev-client": "~55.0.11", "expo-file-system": "~55.0.10", + "expo-image": "~55.0.6", "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "~55.0.8", "expo-media-library": "~55.0.10", "expo-network": "~55.0.9", "expo-router": "~55.0.4", "expo-screen-orientation": "~55.0.8", + "expo-secure-store": "~55.0.9", "expo-status-bar": "~55.0.4", "expo-system-ui": "~55.0.9", "pako": "^2.1.0", @@ -5295,6 +5297,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "55.0.9", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-55.0.9.tgz", + "integrity": "sha512-TIPGjM73LKlebpXwgAu/yL7lNWr6RQYmFw3vgYHOqLFYQMpsBqkQmopovbNX3c/0+RCE9KZlLAkcz8r6detILQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "55.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz", diff --git a/package.json b/package.json index 6d9b720..57b402a 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "expo-constants": "~55.0.9", "expo-dev-client": "~55.0.11", "expo-file-system": "~55.0.10", + "expo-image": "~55.0.6", "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "~55.0.8", "expo-media-library": "~55.0.10", "expo-network": "~55.0.9", "expo-router": "~55.0.4", "expo-screen-orientation": "~55.0.8", + "expo-secure-store": "~55.0.9", "expo-status-bar": "~55.0.4", "expo-system-ui": "~55.0.9", "pako": "^2.1.0", diff --git a/services/types.ts b/services/types.ts index 445b4df..f32c456 100644 --- a/services/types.ts +++ b/services/types.ts @@ -167,3 +167,14 @@ export interface LiveStreamInfo { qn: number; qualities: { qn: number; desc: string }[]; } + +export interface SearchSuggestItem { + value: string; + ref: number; +} + +export interface HotSearchItem { + keyword: string; + show_name: string; + icon?: string; +} diff --git a/store/authStore.ts b/store/authStore.ts index 19afd09..27fae61 100644 --- a/store/authStore.ts +++ b/store/authStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { getUserInfo } from '../services/bilibili'; +import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage'; interface AuthState { sessdata: string | null; @@ -22,21 +23,33 @@ export const useAuthStore = create((set) => ({ isLoggedIn: false, login: async (sessdata, uid, username) => { + await setSecure('SESSDATA', sessdata); await AsyncStorage.multiSet([ - ['SESSDATA', sessdata], ['UID', uid], ['USERNAME', username ?? ''], ]); + // Migrate: remove SESSDATA from AsyncStorage if it was there before + await AsyncStorage.removeItem('SESSDATA').catch(() => {}); set({ sessdata, uid, username: username ?? null, isLoggedIn: true }); }, logout: async () => { - await AsyncStorage.multiRemove(['SESSDATA', 'UID', 'USERNAME', 'FACE']); + await deleteSecure('SESSDATA'); + await AsyncStorage.multiRemove(['UID', 'USERNAME', 'FACE']); set({ sessdata: null, uid: null, username: null, face: null, isLoggedIn: false }); }, restore: async () => { - const sessdata = await AsyncStorage.getItem('SESSDATA'); + // Try SecureStore first, fallback to AsyncStorage for migration + let sessdata = await getSecure('SESSDATA'); + if (!sessdata) { + sessdata = await AsyncStorage.getItem('SESSDATA'); + if (sessdata) { + // Migrate from AsyncStorage to SecureStore + await setSecure('SESSDATA', sessdata); + await AsyncStorage.removeItem('SESSDATA'); + } + } if (sessdata) { set({ sessdata, isLoggedIn: true }); try { diff --git a/utils/theme.ts b/utils/theme.ts index 7265ef5..6673b24 100644 --- a/utils/theme.ts +++ b/utils/theme.ts @@ -1,12 +1,20 @@ import { useSettingsStore } from '../store/settingsStore'; export type ThemeColors = { - bg: string; // 页面/列表背景 - card: string; // 卡片/section 背景 - text: string; // 主文字 - textSub: string; // 次要文字 - border: string; // 分割线 - inputBg: string; // 输入框背景 + bg: string; // 页面/列表背景 + card: string; // 卡片/section 背景 + text: string; // 主文字 + textSub: string; // 次要文字 + border: string; // 分割线 + inputBg: string; // 输入框背景 + sheetBg: string; // 底部弹出面板背景 + modalBg: string; // 弹窗/面板背景 + modalText: string; // 弹窗主文字 + modalTextSub: string; // 弹窗次要文字 + modalBorder: string; // 弹窗分割线 + placeholder: string; // 占位背景(图片加载中) + iconDefault: string; // 默认图标色 + danger: string; // 危险操作色 }; const light: ThemeColors = { @@ -16,6 +24,14 @@ const light: ThemeColors = { textSub: '#999', border: '#eee', inputBg: '#f0f0f0', + sheetBg: '#fff', + modalBg: '#fff', + modalText: '#212121', + modalTextSub: '#555', + modalBorder: '#eee', + placeholder: '#ddd', + iconDefault: '#999', + danger: '#ff4757', }; const dark: ThemeColors = { @@ -25,6 +41,14 @@ const dark: ThemeColors = { textSub: '#666', border: '#2a2a2a', inputBg: '#2a2a2a', + sheetBg: '#222', + modalBg: '#2a2a2a', + modalText: '#e0e0e0', + modalTextSub: '#888', + modalBorder: '#3a3a3a', + placeholder: '#333', + iconDefault: '#777', + danger: '#ff6b81', }; export function useTheme(): ThemeColors {