mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
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:
4
app.json
4
app.json
@@ -41,7 +41,9 @@
|
|||||||
"expo-router",
|
"expo-router",
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
"expo-screen-orientation",
|
"expo-screen-orientation",
|
||||||
"@sentry/react-native/expo"
|
"@sentry/react-native/expo",
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-image"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -202,12 +202,12 @@ function DownloadRow({
|
|||||||
)}
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.actionBtn}
|
style={styles.actionBtn}
|
||||||
onPress={isDownloading ? onDelete : onDelete}
|
onPress={onDelete}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isDownloading ? 'close-circle-outline' : 'trash-outline'}
|
name={isDownloading ? 'close-circle-outline' : 'trash-outline'}
|
||||||
size={20}
|
size={20}
|
||||||
color={isDownloading ? '#bbb' : '#bbb'}
|
color="#bbb"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -334,6 +334,9 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
windowSize={7}
|
||||||
|
maxToRenderPerBatch={6}
|
||||||
|
removeClippedSubviews={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -402,6 +405,9 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
onScroll={onLiveScroll}
|
onScroll={onLiveScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
windowSize={7}
|
||||||
|
maxToRenderPerBatch={6}
|
||||||
|
removeClippedSubviews={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</PagerView>
|
</PagerView>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type Tab = "intro" | "danmaku";
|
|||||||
|
|
||||||
export default function LiveDetailScreen() {
|
export default function LiveDetailScreen() {
|
||||||
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
||||||
console.log("LiveDetailScreen params:", { roomId });
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const id = parseInt(roomId ?? "0", 10);
|
const id = parseInt(roomId ?? "0", 10);
|
||||||
|
|||||||
236
app/search.tsx
236
app/search.tsx
@@ -7,26 +7,50 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
FlatList,
|
FlatList,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { VideoCard } from '../components/VideoCard';
|
import { VideoCard } from '../components/VideoCard';
|
||||||
import { useSearch } from '../hooks/useSearch';
|
import { useSearch, SearchSort } from '../hooks/useSearch';
|
||||||
import { useTheme } from '../utils/theme';
|
import { useTheme } from '../utils/theme';
|
||||||
import type { VideoItem } from '../services/types';
|
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() {
|
export default function SearchScreen() {
|
||||||
const router = useRouter();
|
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 theme = useTheme();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const hasResults = results.length > 0;
|
||||||
|
const hasSearched = hasResults || (loading && results.length === 0);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback((kw?: string) => {
|
||||||
if (keyword.trim()) {
|
const term = (kw ?? keyword).trim();
|
||||||
search(keyword, true);
|
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(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: VideoItem; index: number }) => {
|
({ 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 = () => {
|
const ListEmptyComponent = () => {
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
|
if (!keyword.trim()) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
<Ionicons name="search-outline" size={48} color="#ddd" />
|
<Ionicons name="search-outline" size={48} color="#ddd" />
|
||||||
<Text style={[styles.emptyText, { color: theme.textSub }]}>
|
<Text style={[styles.emptyText, { color: theme.textSub }]}>没有找到相关视频</Text>
|
||||||
{results.length === 0 && keyword.trim()
|
|
||||||
? '没有找到相关视频'
|
|
||||||
: '输入关键词搜索'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -90,7 +135,7 @@ export default function SearchScreen() {
|
|||||||
placeholderTextColor="#999"
|
placeholderTextColor="#999"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChangeText={setKeyword}
|
onChangeText={setKeyword}
|
||||||
onSubmitEditing={handleSearch}
|
onSubmitEditing={() => handleSearch()}
|
||||||
returnKeyType="search"
|
returnKeyType="search"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -102,12 +147,90 @@ export default function SearchScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.searchBtn} onPress={handleSearch}>
|
<TouchableOpacity style={styles.searchBtn} onPress={() => handleSearch()} activeOpacity={0.85}>
|
||||||
<Text style={styles.searchBtnText}>搜索</Text>
|
<Text style={styles.searchBtnText}>搜索</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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
|
<FlatList
|
||||||
data={results}
|
data={results}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -115,6 +238,7 @@ export default function SearchScreen() {
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={<ListHeaderComponent />}
|
||||||
ListEmptyComponent={<ListEmptyComponent />}
|
ListEmptyComponent={<ListEmptyComponent />}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
loading && results.length > 0 ? (
|
loading && results.length > 0 ? (
|
||||||
@@ -125,6 +249,7 @@ export default function SearchScreen() {
|
|||||||
}
|
}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,7 +288,88 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
searchBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
|
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: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 1,
|
paddingHorizontal: 1,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function VideoDetailScreen() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!video?.cid) return;
|
if (!video?.cid) return;
|
||||||
getDanmaku(video.cid).then(setDanmakus);
|
getDanmaku(video.cid).then(setDanmakus).catch(() => {});
|
||||||
}, [video?.cid]);
|
}, [video?.cid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -197,7 +197,11 @@ export default function VideoDetailScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<>
|
<>
|
||||||
<View style={styles.upRow}>
|
<TouchableOpacity
|
||||||
|
style={styles.upRow}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: proxyImageUrl(video.owner.face) }}
|
source={{ uri: proxyImageUrl(video.owner.face) }}
|
||||||
style={styles.avatar}
|
style={styles.avatar}
|
||||||
@@ -213,10 +217,10 @@ export default function VideoDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.followBtn}>
|
<View style={styles.followBtn}>
|
||||||
<Text style={styles.followTxt}>+ 关注</Text>
|
<Text style={styles.followTxt}>查看主页</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.titleSection,
|
styles.titleSection,
|
||||||
@@ -661,6 +665,7 @@ const styles = StyleSheet.create({
|
|||||||
sortRow: {
|
sortRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import React, {
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Image,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
Animated,
|
Animated,
|
||||||
PanResponder,
|
PanResponder,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import Video, { VideoRef } from "react-native-video";
|
import Video, { VideoRef } from "react-native-video";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { buildDashMpdUri } from "../utils/dash";
|
import { buildDashMpdUri } from "../utils/dash";
|
||||||
@@ -103,7 +103,11 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
const detail = await getVideoDetail(item.bvid);
|
const detail = await getVideoDetail(item.bvid);
|
||||||
cid = detail.cid ?? detail.pages?.[0]?.cid;
|
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);
|
const playData = await getPlayUrl(item.bvid, cid, 16);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (playData.dash) {
|
if (playData.dash) {
|
||||||
@@ -263,7 +267,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
|||||||
<Image
|
<Image
|
||||||
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
||||||
style={mediaDimensions}
|
style={mediaDimensions}
|
||||||
resizeMode="cover"
|
contentFit="cover"
|
||||||
|
recyclingKey={item.bvid}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import type { Comment } from '../services/types';
|
import type { Comment } from '../services/types';
|
||||||
import { formatTime } from '../utils/format';
|
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.username}>{item.member.uname}</Text>
|
||||||
<Text style={[styles.message, { color: theme.text }]}>{item.content.message}</Text>
|
<Text style={[styles.message, { color: theme.text }]}>{item.content.message}</Text>
|
||||||
<View style={styles.footer}>
|
<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}>
|
<View style={styles.likeRow}>
|
||||||
<Ionicons name="thumbs-up-outline" size={12} color="#999" />
|
<Ionicons name="thumbs-up-outline" size={12} color={theme.textSub} />
|
||||||
<Text style={styles.likeCount}>{item.like > 0 ? item.like : ''}</Text>
|
<Text style={[styles.likeCount, { color: theme.textSub }]}>{item.like > 0 ? item.like : ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ const FAST_DRIP_INTERVAL = 100;
|
|||||||
const QUEUE_FAST_THRESHOLD = 50;
|
const QUEUE_FAST_THRESHOLD = 50;
|
||||||
const SEEK_THRESHOLD = 2;
|
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 }> = {
|
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
|
||||||
1: { text: "总督", color: "#E13979" },
|
1: { text: "总督", color: "#E13979" },
|
||||||
@@ -153,7 +171,7 @@ export default function DanmakuList({
|
|||||||
if (queueRef.current.length === 0) return;
|
if (queueRef.current.length === 0) return;
|
||||||
|
|
||||||
const item = queueRef.current.shift()!;
|
const item = queueRef.current.shift()!;
|
||||||
const fadeAnim = new Animated.Value(0);
|
const fadeAnim = acquireAnim();
|
||||||
const displayed: DisplayedDanmaku = {
|
const displayed: DisplayedDanmaku = {
|
||||||
...item,
|
...item,
|
||||||
_key: keyCounterRef.current++,
|
_key: keyCounterRef.current++,
|
||||||
@@ -168,9 +186,13 @@ export default function DanmakuList({
|
|||||||
|
|
||||||
setDisplayedItems((prev) => {
|
setDisplayedItems((prev) => {
|
||||||
const next = [...prev, displayed];
|
const next = [...prev, displayed];
|
||||||
return next.length > maxItems
|
if (next.length > maxItems) {
|
||||||
? next.slice(-Math.floor(maxItems / 2))
|
const trimCount = next.length - Math.floor(maxItems / 2);
|
||||||
: next;
|
const trimmed = next.slice(trimCount);
|
||||||
|
releaseAnims(next.slice(0, trimCount));
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAtBottomRef.current) {
|
if (isAtBottomRef.current) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useDownloadStore } from '../store/downloadStore';
|
import { useDownloadStore } from '../store/downloadStore';
|
||||||
|
import { useTheme } from '../utils/theme';
|
||||||
|
|
||||||
const SIZE = 32; // 环外径
|
const SIZE = 32; // 环外径
|
||||||
const RING = 3; // 环宽
|
const RING = 3; // 环宽
|
||||||
@@ -13,6 +14,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadProgressBtn({ onPress }: Props) {
|
export function DownloadProgressBtn({ onPress }: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
const tasks = useDownloadStore((s) => s.tasks);
|
const tasks = useDownloadStore((s) => s.tasks);
|
||||||
const downloading = Object.values(tasks).filter((t) => t.status === 'downloading');
|
const downloading = Object.values(tasks).filter((t) => t.status === 'downloading');
|
||||||
const hasDownloading = downloading.length > 0;
|
const hasDownloading = downloading.length > 0;
|
||||||
@@ -32,7 +34,7 @@ export function DownloadProgressBtn({ onPress }: Props) {
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="cloud-download-outline"
|
name="cloud-download-outline"
|
||||||
size={22}
|
size={22}
|
||||||
color={hasDownloading ? BLUE : '#999'}
|
color={hasDownloading ? BLUE : theme.iconDefault}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useDownload } from "../hooks/useDownload";
|
import { useDownload } from "../hooks/useDownload";
|
||||||
|
import { useTheme } from "../utils/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -30,6 +31,7 @@ export function DownloadSheet({
|
|||||||
qualities,
|
qualities,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { tasks, startDownload, taskKey } = useDownload();
|
const { tasks, startDownload, taskKey } = useDownload();
|
||||||
|
const theme = useTheme();
|
||||||
const slideAnim = useRef(new Animated.Value(300)).current;
|
const slideAnim = useRef(new Animated.Value(300)).current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,21 +57,21 @@ export function DownloadSheet({
|
|||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
/>
|
/>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.sheet, { transform: [{ translateY: slideAnim }] }]}
|
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>下载视频</Text>
|
<Text style={[styles.headerTitle, { color: theme.modalText }]}>下载视频</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||||
<Ionicons name="close" size={20} color="#555" />
|
<Ionicons name="close" size={20} color={theme.modalTextSub} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.divider} />
|
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
|
||||||
{qualities.map((q) => {
|
{qualities.map((q) => {
|
||||||
const key = taskKey(bvid, q.qn);
|
const key = taskKey(bvid, q.qn);
|
||||||
const task = tasks[key];
|
const task = tasks[key];
|
||||||
return (
|
return (
|
||||||
<View key={q.qn} style={styles.row}>
|
<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}>
|
<View style={styles.right}>
|
||||||
{!task && (
|
{!task && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useTheme } from "../utils/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hlsUrl: string;
|
hlsUrl: string;
|
||||||
@@ -87,6 +88,7 @@ function NativeLivePlayer({
|
|||||||
onQualityChange?: (qn: number) => void;
|
onQualityChange?: (qn: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const Video = require("react-native-video").default;
|
const Video = require("react-native-video").default;
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
@@ -277,12 +279,12 @@ function NativeLivePlayer({
|
|||||||
onPress={() => setShowQualityPanel(false)}
|
onPress={() => setShowQualityPanel(false)}
|
||||||
>
|
>
|
||||||
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
|
||||||
<View style={styles.qualityPanel}>
|
<View style={[styles.qualityPanel, { backgroundColor: theme.modalBg }]}>
|
||||||
<Text style={styles.qualityPanelTitle}>清晰度</Text>
|
<Text style={[styles.qualityPanelTitle, { color: theme.modalText }]}>清晰度</Text>
|
||||||
{qualities.map((q) => (
|
{qualities.map((q) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={q.qn}
|
key={q.qn}
|
||||||
style={[styles.qualityItem]}
|
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onQualityChange?.(q.qn);
|
onQualityChange?.(q.qn);
|
||||||
setShowQualityPanel(false);
|
setShowQualityPanel(false);
|
||||||
@@ -291,6 +293,7 @@ function NativeLivePlayer({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.qualityItemText,
|
styles.qualityItemText,
|
||||||
|
{ color: theme.modalTextSub },
|
||||||
currentQn === q.qn && styles.qualityItemTextActive,
|
currentQn === q.qn && styles.qualityItemTextActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import QRCode from "react-native-qrcode-svg";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
|
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
|
import { useTheme } from "../utils/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -33,6 +34,7 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
const setProfile = useAuthStore((s) => s.setProfile);
|
const setProfile = useAuthStore((s) => s.setProfile);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
// sheet 滑入动画
|
// sheet 滑入动画
|
||||||
const slideY = useRef(new Animated.Value(300)).current;
|
const slideY = useRef(new Animated.Value(300)).current;
|
||||||
@@ -69,8 +71,12 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!qrKey || status !== "waiting") return;
|
if (!qrKey || status !== "waiting") return;
|
||||||
|
let cancelled = false;
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
const result = await pollQRCode(qrKey);
|
const result = await pollQRCode(qrKey);
|
||||||
|
if (cancelled) return;
|
||||||
if (result.code === 86038) {
|
if (result.code === 86038) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
@@ -80,16 +86,21 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
try {
|
try {
|
||||||
await login(result.cookie, "", "");
|
await login(result.cookie, "", "");
|
||||||
|
if (cancelled) return;
|
||||||
setStatus("done");
|
setStatus("done");
|
||||||
const info = await getUserInfo();
|
const info = await getUserInfo();
|
||||||
setProfile(info.face, info.uname, String(info.mid));
|
if (!cancelled) setProfile(info.face, info.uname, String(info.mid));
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
if (!cancelled) setStatus("error");
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error during poll — ignore, will retry next interval
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
};
|
};
|
||||||
}, [qrKey, status]);
|
}, [qrKey, status]);
|
||||||
@@ -143,8 +154,8 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
|
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
|
||||||
>
|
>
|
||||||
<View style={styles.sheet}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<Text style={styles.title}>扫码登录</Text>
|
<Text style={[styles.title, { color: theme.modalText }]}>扫码登录</Text>
|
||||||
{status === "loading" && (
|
{status === "loading" && (
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
size="large"
|
size="large"
|
||||||
@@ -172,7 +183,7 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.hint}>
|
<Text style={[styles.hint, { color: theme.modalTextSub }]}>
|
||||||
{status === "scanned"
|
{status === "scanned"
|
||||||
? "扫描成功,请在手机确认"
|
? "扫描成功,请在手机确认"
|
||||||
: "使用 B站 APP 扫一扫"}
|
: "使用 B站 APP 扫一扫"}
|
||||||
@@ -180,7 +191,7 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<Text style={styles.hint}>二维码已过期,请关闭重试</Text>
|
<Text style={[styles.hint, { color: theme.modalTextSub }]}>二维码已过期,请关闭重试</Text>
|
||||||
)}
|
)}
|
||||||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||||
<Text style={styles.closeTxt}>关闭</Text>
|
<Text style={styles.closeTxt}>关闭</Text>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
import { buildDashMpdUri } from "../utils/dash";
|
import { buildDashMpdUri } from "../utils/dash";
|
||||||
import { getVideoShot } from "../services/bilibili";
|
import { getVideoShot } from "../services/bilibili";
|
||||||
import DanmakuOverlay from "./DanmakuOverlay";
|
import DanmakuOverlay from "./DanmakuOverlay";
|
||||||
|
import { useTheme } from "../utils/theme";
|
||||||
|
|
||||||
const BAR_H = 3;
|
const BAR_H = 3;
|
||||||
// 进度球尺寸
|
// 进度球尺寸
|
||||||
@@ -100,6 +101,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|||||||
) {
|
) {
|
||||||
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
||||||
const VIDEO_H = SCREEN_W * 0.5625;
|
const VIDEO_H = SCREEN_W * 0.5625;
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
||||||
const isDash = !!playData?.dash;
|
const isDash = !!playData?.dash;
|
||||||
@@ -569,12 +571,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|||||||
style={styles.modalOverlay}
|
style={styles.modalOverlay}
|
||||||
onPress={() => setShowQuality(false)}
|
onPress={() => setShowQuality(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.qualityList}>
|
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
|
||||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
<Text style={[styles.qualityTitle, { color: theme.modalText }]}>选择清晰度</Text>
|
||||||
{qualities.map((q) => (
|
{qualities.map((q) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={q.qn}
|
key={q.qn}
|
||||||
style={styles.qualityItem}
|
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowQuality(false);
|
setShowQuality(false);
|
||||||
onQualityChange(q.qn);
|
onQualityChange(q.qn);
|
||||||
@@ -584,6 +586,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.qualityItemText,
|
styles.qualityItemText,
|
||||||
|
{ color: theme.modalTextSub },
|
||||||
q.qn === currentQn && styles.qualityItemActive,
|
q.qn === currentQn && styles.qualityItemActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Image,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { VideoItem } from "../services/types";
|
import type { VideoItem } from "../services/types";
|
||||||
import { formatCount, formatDuration } from "../utils/format";
|
import { formatCount, formatDuration } from "../utils/format";
|
||||||
@@ -35,7 +35,9 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props)
|
|||||||
<Image
|
<Image
|
||||||
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
|
||||||
style={[styles.thumb, { backgroundColor: theme.card }]}
|
style={[styles.thumb, { backgroundColor: theme.card }]}
|
||||||
resizeMode="cover"
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
recyclingKey={item.bvid}
|
||||||
/>
|
/>
|
||||||
<View style={styles.meta}>
|
<View style={styles.meta}>
|
||||||
<Ionicons name="play" size={11} color="#fff" />
|
<Ionicons name="play" size={11} color="#fff" />
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { searchVideos } from '../services/bilibili';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import type { VideoItem } from '../services/types';
|
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() {
|
export function useSearch() {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
@@ -8,18 +27,74 @@ export function useSearch() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
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 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;
|
if (!kw.trim() || loadingRef.current) return;
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setSuggestions([]);
|
||||||
|
const activeSort = sortOverride ?? currentSort.current;
|
||||||
const currentPage = reset ? 1 : page;
|
const currentPage = reset ? 1 : page;
|
||||||
|
const orderParam = activeSort === 'pubdate' ? 'pubdate' : activeSort === 'view' ? 'click' : '';
|
||||||
try {
|
try {
|
||||||
const items = await searchVideos(kw, currentPage);
|
const items = await searchVideos(kw, currentPage, orderParam);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setResults(items);
|
setResults(items);
|
||||||
setPage(2);
|
setPage(2);
|
||||||
|
addToHistory(kw);
|
||||||
} else {
|
} else {
|
||||||
setResults(prev => [...prev, ...items]);
|
setResults(prev => [...prev, ...items]);
|
||||||
setPage(p => p + 1);
|
setPage(p => p + 1);
|
||||||
@@ -31,12 +106,28 @@ export function useSearch() {
|
|||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setLoading(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(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (!keyword.trim() || loadingRef.current || !hasMore) return;
|
if (!keyword.trim() || loadingRef.current || !hasMore) return;
|
||||||
search(keyword, false);
|
search(keyword, false);
|
||||||
}, [keyword, hasMore, search]);
|
}, [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
15
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "jkvideo",
|
"name": "jkvideo",
|
||||||
"version": "1.0.6",
|
"version": "1.0.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jkvideo",
|
"name": "jkvideo",
|
||||||
"version": "1.0.6",
|
"version": "1.0.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dr.pogodin/react-native-static-server": "^0.26.0",
|
"@dr.pogodin/react-native-static-server": "^0.26.0",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
@@ -19,12 +19,14 @@
|
|||||||
"expo-constants": "~55.0.9",
|
"expo-constants": "~55.0.9",
|
||||||
"expo-dev-client": "~55.0.11",
|
"expo-dev-client": "~55.0.11",
|
||||||
"expo-file-system": "~55.0.10",
|
"expo-file-system": "~55.0.10",
|
||||||
|
"expo-image": "~55.0.6",
|
||||||
"expo-intent-launcher": "~55.0.9",
|
"expo-intent-launcher": "~55.0.9",
|
||||||
"expo-linear-gradient": "~55.0.8",
|
"expo-linear-gradient": "~55.0.8",
|
||||||
"expo-media-library": "~55.0.10",
|
"expo-media-library": "~55.0.10",
|
||||||
"expo-network": "~55.0.9",
|
"expo-network": "~55.0.9",
|
||||||
"expo-router": "~55.0.4",
|
"expo-router": "~55.0.4",
|
||||||
"expo-screen-orientation": "~55.0.8",
|
"expo-screen-orientation": "~55.0.8",
|
||||||
|
"expo-secure-store": "~55.0.9",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
"expo-system-ui": "~55.0.9",
|
"expo-system-ui": "~55.0.9",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
@@ -5295,6 +5297,15 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-server": {
|
||||||
"version": "55.0.6",
|
"version": "55.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz",
|
||||||
|
|||||||
@@ -21,12 +21,14 @@
|
|||||||
"expo-constants": "~55.0.9",
|
"expo-constants": "~55.0.9",
|
||||||
"expo-dev-client": "~55.0.11",
|
"expo-dev-client": "~55.0.11",
|
||||||
"expo-file-system": "~55.0.10",
|
"expo-file-system": "~55.0.10",
|
||||||
|
"expo-image": "~55.0.6",
|
||||||
"expo-intent-launcher": "~55.0.9",
|
"expo-intent-launcher": "~55.0.9",
|
||||||
"expo-linear-gradient": "~55.0.8",
|
"expo-linear-gradient": "~55.0.8",
|
||||||
"expo-media-library": "~55.0.10",
|
"expo-media-library": "~55.0.10",
|
||||||
"expo-network": "~55.0.9",
|
"expo-network": "~55.0.9",
|
||||||
"expo-router": "~55.0.4",
|
"expo-router": "~55.0.4",
|
||||||
"expo-screen-orientation": "~55.0.8",
|
"expo-screen-orientation": "~55.0.8",
|
||||||
|
"expo-secure-store": "~55.0.9",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
"expo-system-ui": "~55.0.9",
|
"expo-system-ui": "~55.0.9",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
|
|||||||
@@ -167,3 +167,14 @@ export interface LiveStreamInfo {
|
|||||||
qn: number;
|
qn: number;
|
||||||
qualities: { qn: number; desc: string }[];
|
qualities: { qn: number; desc: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchSuggestItem {
|
||||||
|
value: string;
|
||||||
|
ref: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotSearchItem {
|
||||||
|
keyword: string;
|
||||||
|
show_name: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { getUserInfo } from '../services/bilibili';
|
import { getUserInfo } from '../services/bilibili';
|
||||||
|
import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
sessdata: string | null;
|
sessdata: string | null;
|
||||||
@@ -22,21 +23,33 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
|
||||||
login: async (sessdata, uid, username) => {
|
login: async (sessdata, uid, username) => {
|
||||||
|
await setSecure('SESSDATA', sessdata);
|
||||||
await AsyncStorage.multiSet([
|
await AsyncStorage.multiSet([
|
||||||
['SESSDATA', sessdata],
|
|
||||||
['UID', uid],
|
['UID', uid],
|
||||||
['USERNAME', username ?? ''],
|
['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 });
|
set({ sessdata, uid, username: username ?? null, isLoggedIn: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
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 });
|
set({ sessdata: null, uid: null, username: null, face: null, isLoggedIn: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
restore: async () => {
|
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) {
|
if (sessdata) {
|
||||||
set({ sessdata, isLoggedIn: true });
|
set({ sessdata, isLoggedIn: true });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ export type ThemeColors = {
|
|||||||
textSub: string; // 次要文字
|
textSub: string; // 次要文字
|
||||||
border: string; // 分割线
|
border: string; // 分割线
|
||||||
inputBg: string; // 输入框背景
|
inputBg: string; // 输入框背景
|
||||||
|
sheetBg: string; // 底部弹出面板背景
|
||||||
|
modalBg: string; // 弹窗/面板背景
|
||||||
|
modalText: string; // 弹窗主文字
|
||||||
|
modalTextSub: string; // 弹窗次要文字
|
||||||
|
modalBorder: string; // 弹窗分割线
|
||||||
|
placeholder: string; // 占位背景(图片加载中)
|
||||||
|
iconDefault: string; // 默认图标色
|
||||||
|
danger: string; // 危险操作色
|
||||||
};
|
};
|
||||||
|
|
||||||
const light: ThemeColors = {
|
const light: ThemeColors = {
|
||||||
@@ -16,6 +24,14 @@ const light: ThemeColors = {
|
|||||||
textSub: '#999',
|
textSub: '#999',
|
||||||
border: '#eee',
|
border: '#eee',
|
||||||
inputBg: '#f0f0f0',
|
inputBg: '#f0f0f0',
|
||||||
|
sheetBg: '#fff',
|
||||||
|
modalBg: '#fff',
|
||||||
|
modalText: '#212121',
|
||||||
|
modalTextSub: '#555',
|
||||||
|
modalBorder: '#eee',
|
||||||
|
placeholder: '#ddd',
|
||||||
|
iconDefault: '#999',
|
||||||
|
danger: '#ff4757',
|
||||||
};
|
};
|
||||||
|
|
||||||
const dark: ThemeColors = {
|
const dark: ThemeColors = {
|
||||||
@@ -25,6 +41,14 @@ const dark: ThemeColors = {
|
|||||||
textSub: '#666',
|
textSub: '#666',
|
||||||
border: '#2a2a2a',
|
border: '#2a2a2a',
|
||||||
inputBg: '#2a2a2a',
|
inputBg: '#2a2a2a',
|
||||||
|
sheetBg: '#222',
|
||||||
|
modalBg: '#2a2a2a',
|
||||||
|
modalText: '#e0e0e0',
|
||||||
|
modalTextSub: '#888',
|
||||||
|
modalBorder: '#3a3a3a',
|
||||||
|
placeholder: '#333',
|
||||||
|
iconDefault: '#777',
|
||||||
|
danger: '#ff6b81',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useTheme(): ThemeColors {
|
export function useTheme(): ThemeColors {
|
||||||
|
|||||||
Reference in New Issue
Block a user