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 {