mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user