feat: 性能优化 + Bug修复 + 搜索增强

- expo-image 替换 RN Image(VideoCard/LiveCard/BigVideoCard/CommentItem,recyclingKey)
- DanmakuList Animated.Value 对象池,减少 GC 压力
- FlatList 性能参数:windowSize=7 / maxToRenderPerBatch=6 / removeClippedSubviews
- bilibili.ts 请求去重(getVideoDetail/getPlayUrl)
- SESSDATA 迁移至 expo-secure-store(utils/secureStorage.ts,启动自动迁移)
- 主题系统扩展:新增 sheetBg/modalBg/modalText/placeholder/iconDefault/danger 等 token
- 多组件深色模式适配:DownloadSheet/LivePlayer/NativeVideoPlayer/DownloadProgressBtn/CommentItem/LoginModal
- 搜索页增强:搜索建议 + 热搜榜(hooks/useSearch.ts + app/search.tsx)
- LoginModal 修复轮询竞态(cancelled flag + try-catch)
- 修复 downloads.tsx 冗余三元表达式
This commit is contained in:
Developer
2026-03-26 00:43:37 +08:00
parent 27587859a4
commit 463c0db058
21 changed files with 524 additions and 103 deletions

View File

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

View File

@@ -202,12 +202,12 @@ function DownloadRow({
)}
<TouchableOpacity
style={styles.actionBtn}
onPress={isDownloading ? onDelete : onDelete}
onPress={onDelete}
>
<Ionicons
name={isDownloading ? 'close-circle-outline' : 'trash-outline'}
size={20}
color={isDownloading ? '#bbb' : '#bbb'}
color="#bbb"
/>
</TouchableOpacity>
</View>

View File

@@ -334,6 +334,9 @@ export default function HomeScreen() {
}
onScroll={onScroll}
scrollEventThrottle={16}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
@@ -402,6 +405,9 @@ export default function HomeScreen() {
}
onScroll={onLiveScroll}
scrollEventThrottle={16}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
</PagerView>

View File

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

View File

@@ -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<TextInput>(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 (
<View style={[styles.sortBar, { backgroundColor: theme.card }]}>
{SORT_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.key}
style={[styles.sortBtn, sort === opt.key && styles.sortBtnActive]}
onPress={() => changeSort(opt.key)}
activeOpacity={0.85}
>
<Text style={[styles.sortBtnText, sort === opt.key && styles.sortBtnTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
);
}, [hasResults, sort, changeSort, theme.card]);
const ListEmptyComponent = () => {
if (loading) return null;
if (!keyword.trim()) return null;
return (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={[styles.emptyText, { color: theme.textSub }]}>
{results.length === 0 && keyword.trim()
? '没有找到相关视频'
: '输入关键词搜索'}
</Text>
<Text style={[styles.emptyText, { color: theme.textSub }]}></Text>
</View>
);
};
@@ -90,7 +135,7 @@ export default function SearchScreen() {
placeholderTextColor="#999"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={handleSearch}
onSubmitEditing={() => handleSearch()}
returnKeyType="search"
autoFocus
autoCapitalize="none"
@@ -102,12 +147,90 @@ export default function SearchScreen() {
</TouchableOpacity>
)}
</View>
<TouchableOpacity style={styles.searchBtn} onPress={handleSearch}>
<TouchableOpacity style={styles.searchBtn} onPress={() => handleSearch()} activeOpacity={0.85}>
<Text style={styles.searchBtnText}></Text>
</TouchableOpacity>
</View>
{/* Results */}
{/* Suggestions dropdown */}
{showSuggestions && (
<View style={[styles.suggestPanel, { backgroundColor: theme.card }]}>
{suggestions.map((s, i) => (
<TouchableOpacity
key={`${s.value}-${i}`}
style={[styles.suggestItem, { borderBottomColor: theme.border }]}
onPress={() => handleSuggestionPress(s.value)}
activeOpacity={0.85}
>
<Ionicons name="search-outline" size={14} color="#bbb" style={styles.suggestIcon} />
<Text style={[styles.suggestText, { color: theme.text }]} numberOfLines={1}>{s.value}</Text>
</TouchableOpacity>
))}
</View>
)}
{/* Pre-search: history + hot searches */}
{showPreSearch && !showSuggestions ? (
<ScrollView style={styles.preSearch} keyboardShouldPersistTaps="handled">
{/* Search history */}
{history.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
<TouchableOpacity onPress={clearHistory} activeOpacity={0.85}>
<Ionicons name="trash-outline" size={16} color={theme.textSub} />
</TouchableOpacity>
</View>
<View style={styles.tagWrap}>
{history.map(h => (
<TouchableOpacity
key={h}
style={[styles.tag, { backgroundColor: theme.inputBg }]}
onPress={() => handleSearch(h)}
onLongPress={() => removeFromHistory(h)}
activeOpacity={0.85}
>
<Text style={[styles.tagText, { color: theme.text }]} numberOfLines={1}>{h}</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
{/* Hot searches */}
{hotSearches.length > 0 && (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
{hotSearches.map((item, idx) => (
<TouchableOpacity
key={item.keyword}
style={[styles.hotItem, { borderBottomColor: theme.border }]}
onPress={() => handleSearch(item.keyword)}
activeOpacity={0.85}
>
<Text style={[
styles.hotIndex,
idx < 3 && styles.hotIndexTop,
]}>
{idx + 1}
</Text>
<Text style={[styles.hotText, { color: theme.text }]} numberOfLines={1}>
{item.show_name}
</Text>
</TouchableOpacity>
))}
</View>
)}
{history.length === 0 && hotSearches.length === 0 && (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={[styles.emptyText, { color: theme.textSub }]}></Text>
</View>
)}
</ScrollView>
) : (
/* Results list */
<FlatList
data={results}
keyExtractor={keyExtractor}
@@ -115,6 +238,7 @@ export default function SearchScreen() {
contentContainerStyle={styles.listContent}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={<ListHeaderComponent />}
ListEmptyComponent={<ListEmptyComponent />}
ListFooterComponent={
loading && results.length > 0 ? (
@@ -125,6 +249,7 @@ export default function SearchScreen() {
}
keyboardShouldPersistTaps="handled"
/>
)}
</SafeAreaView>
);
}
@@ -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,

View File

@@ -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={
<>
<View style={styles.upRow}>
<TouchableOpacity
style={styles.upRow}
activeOpacity={0.85}
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
@@ -213,10 +217,10 @@ export default function VideoDetailScreen() {
</Text>
)}
</View>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
<View style={styles.followBtn}>
<Text style={styles.followTxt}></Text>
</View>
</TouchableOpacity>
<View
style={[
styles.titleSection,
@@ -661,6 +665,7 @@ const styles = StyleSheet.create({
sortRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,

View File

@@ -9,13 +9,13 @@ import React, {
import {
View,
Text,
Image,
TouchableOpacity,
StyleSheet,
useWindowDimensions,
Animated,
PanResponder,
} from "react-native";
import { Image } from "expo-image";
import Video, { VideoRef } from "react-native-video";
import { Ionicons } from "@expo/vector-icons";
import { buildDashMpdUri } from "../utils/dash";
@@ -103,7 +103,11 @@ export const BigVideoCard = React.memo(function BigVideoCard({
const detail = await getVideoDetail(item.bvid);
cid = detail.cid ?? detail.pages?.[0]?.cid;
}
if (!cid || cancelled) return;
if (!cid) {
console.warn('BigVideoCard: no cid available for', item.bvid);
return;
}
if (cancelled) return;
const playData = await getPlayUrl(item.bvid, cid, 16);
if (cancelled) return;
if (playData.dash) {
@@ -263,7 +267,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={mediaDimensions}
resizeMode="cover"
contentFit="cover"
recyclingKey={item.bvid}
/>
</Animated.View>

View File

@@ -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) {
<Text style={styles.username}>{item.member.uname}</Text>
<Text style={[styles.message, { color: theme.text }]}>{item.content.message}</Text>
<View style={styles.footer}>
<Text style={styles.time}>{formatTime(item.ctime)}</Text>
<Text style={[styles.time, { color: theme.textSub }]}>{formatTime(item.ctime)}</Text>
<View style={styles.likeRow}>
<Ionicons name="thumbs-up-outline" size={12} color="#999" />
<Text style={styles.likeCount}>{item.like > 0 ? item.like : ''}</Text>
<Ionicons name="thumbs-up-outline" size={12} color={theme.textSub} />
<Text style={[styles.likeCount, { color: theme.textSub }]}>{item.like > 0 ? item.like : ''}</Text>
</View>
</View>
</View>

View File

@@ -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<number, { text: string; color: string }> = {
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) {

View File

@@ -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) {
<Ionicons
name="cloud-download-outline"
size={22}
color={hasDownloading ? BLUE : '#999'}
color={hasDownloading ? BLUE : theme.iconDefault}
/>
</TouchableOpacity>
);

View File

@@ -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}
/>
<Animated.View
style={[styles.sheet, { transform: [{ translateY: slideAnim }] }]}
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
>
<View style={styles.header}>
<Text style={styles.headerTitle}></Text>
<Text style={[styles.headerTitle, { color: theme.modalText }]}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Ionicons name="close" size={20} color="#555" />
<Ionicons name="close" size={20} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
<View style={styles.divider} />
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
{qualities.map((q) => {
const key = taskKey(bvid, q.qn);
const task = tasks[key];
return (
<View key={q.qn} style={styles.row}>
<Text style={styles.qualityLabel}>{q.desc}</Text>
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text>
<View style={styles.right}>
{!task && (
<TouchableOpacity

View File

@@ -10,6 +10,7 @@ import {
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "../utils/theme";
interface Props {
hlsUrl: string;
@@ -87,6 +88,7 @@ function NativeLivePlayer({
onQualityChange?: (qn: number) => 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)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.qualityPanel}>
<Text style={styles.qualityPanelTitle}></Text>
<View style={[styles.qualityPanel, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityPanelTitle, { color: theme.modalText }]}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={[styles.qualityItem]}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
onQualityChange?.(q.qn);
setShowQualityPanel(false);
@@ -291,6 +293,7 @@ function NativeLivePlayer({
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
currentQn === q.qn && styles.qualityItemTextActive,
]}
>

View File

@@ -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<ReturnType<typeof setInterval> | 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,8 +71,12 @@ export function LoginModal({ visible, onClose }: Props) {
useEffect(() => {
if (!qrKey || status !== "waiting") return;
let cancelled = false;
pollRef.current = setInterval(async () => {
if (cancelled) return;
try {
const result = await pollQRCode(qrKey);
if (cancelled) return;
if (result.code === 86038) {
setStatus("error");
clearInterval(pollRef.current!);
@@ -80,16 +86,21 @@ export function LoginModal({ visible, onClose }: Props) {
clearInterval(pollRef.current!);
try {
await login(result.cookie, "", "");
if (cancelled) return;
setStatus("done");
const info = await getUserInfo();
setProfile(info.face, info.uname, String(info.mid));
if (!cancelled) setProfile(info.face, info.uname, String(info.mid));
} catch {
setStatus("error");
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) {
<Animated.View
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
>
<View style={styles.sheet}>
<Text style={styles.title}></Text>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<Text style={[styles.title, { color: theme.modalText }]}></Text>
{status === "loading" && (
<ActivityIndicator
size="large"
@@ -172,7 +183,7 @@ export function LoginModal({ visible, onClose }: Props) {
)}
</TouchableOpacity>
</View>
<Text style={styles.hint}>
<Text style={[styles.hint, { color: theme.modalTextSub }]}>
{status === "scanned"
? "扫描成功,请在手机确认"
: "使用 B站 APP 扫一扫"}
@@ -180,7 +191,7 @@ export function LoginModal({ visible, onClose }: Props) {
</>
)}
{status === "error" && (
<Text style={styles.hint}></Text>
<Text style={[styles.hint, { color: theme.modalTextSub }]}></Text>
)}
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<Text style={styles.closeTxt}></Text>

View File

@@ -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<NativeVideoPlayerRef, Props>(
) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
const theme = useTheme();
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
const isDash = !!playData?.dash;
@@ -569,12 +571,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
style={styles.modalOverlay}
onPress={() => setShowQuality(false)}
>
<View style={styles.qualityList}>
<Text style={styles.qualityTitle}></Text>
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityTitle, { color: theme.modalText }]}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={styles.qualityItem}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
setShowQuality(false);
onQualityChange(q.qn);
@@ -584,6 +586,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
q.qn === currentQn && styles.qualityItemActive,
]}
>

View File

@@ -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)
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={[styles.thumb, { backgroundColor: theme.card }]}
resizeMode="cover"
contentFit="cover"
transition={200}
recyclingKey={item.bvid}
/>
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />

View File

@@ -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<string[]> {
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<SearchSort>('default');
const [history, setHistory] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<SearchSuggestItem[]>([]);
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
const loadingRef = useRef(false);
const suggestTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentSort = useRef<SearchSort>('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,
};
}

15
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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<AuthState>((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 {

View File

@@ -7,6 +7,14 @@ export type ThemeColors = {
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 {