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",
|
||||
"react-native-video",
|
||||
"expo-screen-orientation",
|
||||
"@sentry/react-native/expo"
|
||||
"@sentry/react-native/expo",
|
||||
"expo-secure-store",
|
||||
"expo-image"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
268
app/search.tsx
268
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<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,29 +147,109 @@ 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 */}
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListEmptyComponent={<ListEmptyComponent />}
|
||||
ListFooterComponent={
|
||||
loading && results.length > 0 ? (
|
||||
<View style={styles.footer}>
|
||||
<ActivityIndicator color="#00AEEC" />
|
||||
{/* 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>
|
||||
) : null
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={<ListHeaderComponent />}
|
||||
ListEmptyComponent={<ListEmptyComponent />}
|
||||
ListFooterComponent={
|
||||
loading && results.length > 0 ? (
|
||||
<View style={styles.footer}>
|
||||
<ActivityIndicator color="#00AEEC" />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
<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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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,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) {
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user