mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
优化
This commit is contained in:
263
app/index.tsx
263
app/index.tsx
@@ -18,8 +18,8 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Linking,
|
Linking,
|
||||||
useWindowDimensions,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import PagerView from "react-native-pager-view";
|
||||||
import {
|
import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
useSafeAreaInsets,
|
useSafeAreaInsets,
|
||||||
@@ -83,10 +83,9 @@ export default function HomeScreen() {
|
|||||||
const [activeTab, setActiveTab] = useState<TabKey>("hot");
|
const [activeTab, setActiveTab] = useState<TabKey>("hot");
|
||||||
const [liveAreaId, setLiveAreaId] = useState(0);
|
const [liveAreaId, setLiveAreaId] = useState(0);
|
||||||
|
|
||||||
const { width: SCREEN_W } = useWindowDimensions();
|
|
||||||
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
||||||
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
|
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
|
||||||
const tabAnim = useRef(new Animated.Value(0)).current;
|
const pagerRef = useRef<PagerView>(null);
|
||||||
|
|
||||||
const hotListRef = useRef<FlatList>(null);
|
const hotListRef = useRef<FlatList>(null);
|
||||||
const liveListRef = useRef<FlatList>(null);
|
const liveListRef = useRef<FlatList>(null);
|
||||||
@@ -163,20 +162,22 @@ export default function HomeScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 切换 tab
|
// 切换 tab
|
||||||
|
pagerRef.current?.setPage(key === "hot" ? 0 : 1);
|
||||||
setActiveTab(key);
|
setActiveTab(key);
|
||||||
Animated.timing(tabAnim, {
|
|
||||||
toValue: key === "hot" ? 0 : 1,
|
|
||||||
duration: 250,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
if (key === "live" && rooms.length === 0) {
|
if (key === "live" && rooms.length === 0) {
|
||||||
liveLoad(true, liveAreaId);
|
liveLoad(true, liveAreaId);
|
||||||
}
|
}
|
||||||
// 重置 header 动画
|
},
|
||||||
if (key === "hot") {
|
[activeTab, rooms.length, liveAreaId],
|
||||||
scrollY.setValue(0);
|
);
|
||||||
} else {
|
|
||||||
liveScrollY.setValue(0);
|
const onPageSelected = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
const key: TabKey = e.nativeEvent.position === 0 ? "hot" : "live";
|
||||||
|
if (key === activeTab) return;
|
||||||
|
setActiveTab(key);
|
||||||
|
if (key === "live" && rooms.length === 0) {
|
||||||
|
liveLoad(true, liveAreaId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeTab, rooms.length, liveAreaId],
|
[activeTab, rooms.length, liveAreaId],
|
||||||
@@ -192,13 +193,16 @@ export default function HomeScreen() {
|
|||||||
[liveAreaId, liveLoad],
|
[liveAreaId, liveLoad],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const visibleBigKeyRef = useRef(visibleBigKey);
|
||||||
|
visibleBigKeyRef.current = visibleBigKey;
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item: row }: { item: ListRow }) => {
|
({ item: row }: { item: ListRow }) => {
|
||||||
if (row.type === "big") {
|
if (row.type === "big") {
|
||||||
return (
|
return (
|
||||||
<BigVideoCard
|
<BigVideoCard
|
||||||
item={row.item}
|
item={row.item}
|
||||||
isVisible={visibleBigKey === row.item.bvid}
|
isVisible={visibleBigKeyRef.current === row.item.bvid}
|
||||||
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
|
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -237,18 +241,18 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[visibleBigKey],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLiveItem = useCallback(
|
const renderLiveItem = useCallback(
|
||||||
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={styles.leftCol}>
|
<View style={styles.leftCol}>
|
||||||
<LiveCard item={item.left} />
|
<LiveCard item={item.left} />
|
||||||
</View>
|
</View>
|
||||||
{item.right && (
|
{item.right && (
|
||||||
<View style={styles.rightCol}>
|
<View style={styles.rightCol}>
|
||||||
<LiveCard item={item.right} />
|
<LiveCard item={item.right} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -270,127 +274,121 @@ export default function HomeScreen() {
|
|||||||
const currentHeaderOpacity =
|
const currentHeaderOpacity =
|
||||||
activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
|
activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
|
||||||
|
|
||||||
const tabSlideX = tabAnim.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0, -SCREEN_W],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
|
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
|
||||||
{/* 滑动切换容器 */}
|
{/* 滑动切换容器 */}
|
||||||
<View style={styles.tabSlideOuter}>
|
<PagerView
|
||||||
<Animated.View
|
ref={pagerRef}
|
||||||
style={[
|
style={styles.pager}
|
||||||
styles.tabSlideRow,
|
initialPage={0}
|
||||||
{ width: SCREEN_W * 2, transform: [{ translateX: tabSlideX }] },
|
scrollEnabled={false}
|
||||||
]}
|
onPageSelected={onPageSelected}
|
||||||
>
|
>
|
||||||
{/* 热门列表 */}
|
{/* 热门列表 */}
|
||||||
<View style={{ width: SCREEN_W, height: "100%" }}>
|
<View key="hot" collapsable={false}>
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
ref={hotListRef as any}
|
ref={hotListRef as any}
|
||||||
style={styles.listContainer}
|
style={styles.listContainer}
|
||||||
data={rows}
|
data={rows}
|
||||||
keyExtractor={(row: any) =>
|
keyExtractor={(row: any) =>
|
||||||
row.type === "big"
|
row.type === "big"
|
||||||
? `big-${row.item.bvid}`
|
? `big-${row.item.bvid}`
|
||||||
: row.type === "live"
|
: row.type === "live"
|
||||||
? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
|
? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
|
||||||
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
|
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
|
||||||
}
|
}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + NAV_H + 6,
|
paddingTop: insets.top + NAV_H + 6,
|
||||||
paddingBottom: insets.bottom + 16,
|
paddingBottom: insets.bottom + 16,
|
||||||
}}
|
}}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
progressViewOffset={insets.top + NAV_H}
|
progressViewOffset={insets.top + NAV_H}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onEndReached={() => load()}
|
onEndReached={() => load()}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
extraData={visibleBigKey}
|
extraData={visibleBigKey}
|
||||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||||
onViewableItemsChanged={onViewableItemsChangedRef}
|
onViewableItemsChanged={onViewableItemsChangedRef}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{loading && <ActivityIndicator color="#00AEEC" />}
|
{loading && <ActivityIndicator color="#00AEEC" />}
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 直播列表 */}
|
{/* 直播列表 */}
|
||||||
<View style={{ width: SCREEN_W, height: "100%" }}>
|
<View key="live" collapsable={false}>
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
ref={liveListRef as any}
|
ref={liveListRef as any}
|
||||||
style={styles.listContainer}
|
style={styles.listContainer}
|
||||||
data={liveRows}
|
data={liveRows}
|
||||||
keyExtractor={(item: any, index: number) =>
|
keyExtractor={(item: any, index: number) =>
|
||||||
`live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}`
|
`live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}`
|
||||||
}
|
}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + NAV_H + 6,
|
paddingTop: insets.top + NAV_H + 6,
|
||||||
paddingBottom: insets.bottom + 16,
|
paddingBottom: insets.bottom + 16,
|
||||||
}}
|
}}
|
||||||
renderItem={renderLiveItem}
|
renderItem={renderLiveItem}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.areaTabRow}
|
style={styles.areaTabRow}
|
||||||
contentContainerStyle={styles.areaTabContent}
|
contentContainerStyle={styles.areaTabContent}
|
||||||
>
|
>
|
||||||
{LIVE_AREAS.map((area) => (
|
{LIVE_AREAS.map((area) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={area.id}
|
key={area.id}
|
||||||
|
style={[
|
||||||
|
styles.areaTab,
|
||||||
|
liveAreaId === area.id && styles.areaTabActive,
|
||||||
|
]}
|
||||||
|
onPress={() => handleLiveAreaPress(area.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.areaTab,
|
styles.areaTabText,
|
||||||
liveAreaId === area.id && styles.areaTabActive,
|
liveAreaId === area.id && styles.areaTabTextActive,
|
||||||
]}
|
]}
|
||||||
onPress={() => handleLiveAreaPress(area.id)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
>
|
||||||
<Text
|
{area.name}
|
||||||
style={[
|
</Text>
|
||||||
styles.areaTabText,
|
</TouchableOpacity>
|
||||||
liveAreaId === area.id && styles.areaTabTextActive,
|
))}
|
||||||
]}
|
</ScrollView>
|
||||||
>
|
}
|
||||||
{area.name}
|
refreshControl={
|
||||||
</Text>
|
<RefreshControl
|
||||||
</TouchableOpacity>
|
refreshing={liveRefreshing}
|
||||||
))}
|
onRefresh={() => liveRefresh(liveAreaId)}
|
||||||
</ScrollView>
|
progressViewOffset={insets.top + NAV_H}
|
||||||
}
|
/>
|
||||||
refreshControl={
|
}
|
||||||
<RefreshControl
|
onEndReached={() => liveLoad()}
|
||||||
refreshing={liveRefreshing}
|
onEndReachedThreshold={1.5}
|
||||||
onRefresh={() => liveRefresh(liveAreaId)}
|
ListFooterComponent={
|
||||||
progressViewOffset={insets.top + NAV_H}
|
liveLoading ? (
|
||||||
/>
|
<View style={styles.footer}>
|
||||||
}
|
<ActivityIndicator color="#00AEEC" />
|
||||||
onEndReached={() => liveLoad()}
|
<Text style={styles.footerText}>加载中...</Text>
|
||||||
onEndReachedThreshold={1.5}
|
</View>
|
||||||
ListFooterComponent={
|
) : null
|
||||||
liveLoading ? (
|
}
|
||||||
<View style={styles.footer}>
|
onScroll={onLiveScroll}
|
||||||
<ActivityIndicator color="#00AEEC" />
|
scrollEventThrottle={16}
|
||||||
<Text style={styles.footerText}>加载中...</Text>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null
|
</PagerView>
|
||||||
}
|
|
||||||
onScroll={onLiveScroll}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 绝对定位导航栏 */}
|
{/* 绝对定位导航栏 */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -461,8 +459,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safe: { flex: 1, backgroundColor: "#f4f4f4" },
|
safe: { flex: 1, backgroundColor: "#f4f4f4" },
|
||||||
tabSlideOuter: { flex: 1, overflow: "hidden" },
|
pager: { flex: 1 },
|
||||||
tabSlideRow: { flexDirection: "row", height: "100%" },
|
|
||||||
listContainer: { flex: 1 },
|
listContainer: { flex: 1 },
|
||||||
navBar: {
|
navBar: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
// components/BigVideoCard.tsx
|
// components/BigVideoCard.tsx
|
||||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -37,10 +43,16 @@ function clamp(v: number, lo: number, hi: number) {
|
|||||||
interface Props {
|
interface Props {
|
||||||
item: VideoItem;
|
item: VideoItem;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
isScrolling?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
export const BigVideoCard = React.memo(function BigVideoCard({
|
||||||
|
item,
|
||||||
|
isVisible,
|
||||||
|
isScrolling,
|
||||||
|
onPress,
|
||||||
|
}: Props) {
|
||||||
const { width: SCREEN_W } = useWindowDimensions();
|
const { width: SCREEN_W } = useWindowDimensions();
|
||||||
const THUMB_H = SCREEN_W * 0.5625;
|
const THUMB_H = SCREEN_W * 0.5625;
|
||||||
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
||||||
@@ -74,9 +86,9 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
thumbOpacity.setValue(1);
|
thumbOpacity.setValue(1);
|
||||||
}, [item.bvid]);
|
}, [item.bvid]);
|
||||||
|
|
||||||
// Fetch play URL when visible for the first time
|
// Preload: fetch play URL on mount (before card is visible)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || videoUrl) return;
|
if (videoUrl) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -106,24 +118,36 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isVisible, item.bvid]);
|
}, [item.bvid]);
|
||||||
|
|
||||||
// Pause/resume when visibility changes
|
// Pause/resume based on visibility and scroll state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoUrl) return;
|
if (!videoUrl) return;
|
||||||
setPaused(!isVisible);
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
|
// Off-screen: pause, mute, show thumbnail
|
||||||
|
setPaused(true);
|
||||||
setMuted(true);
|
setMuted(true);
|
||||||
Animated.timing(thumbOpacity, {
|
Animated.timing(thumbOpacity, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 150,
|
duration: 150,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
} else if (isScrolling) {
|
||||||
|
// Visible but scrolling: just pause (keep thumbnail hidden, keep mute state)
|
||||||
|
setPaused(true);
|
||||||
|
} else {
|
||||||
|
// Visible and not scrolling: play, fade out thumbnail
|
||||||
|
setPaused(false);
|
||||||
|
Animated.timing(thumbOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
}
|
}
|
||||||
}, [isVisible, videoUrl]);
|
}, [isVisible, isScrolling, videoUrl]);
|
||||||
|
|
||||||
const handleVideoReady = () => {
|
const handleVideoReady = () => {
|
||||||
if (!isVisible) return;
|
if (!isVisible || isScrolling) return;
|
||||||
setPaused(false);
|
setPaused(false);
|
||||||
Animated.timing(thumbOpacity, {
|
Animated.timing(thumbOpacity, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
@@ -142,44 +166,62 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
|
|
||||||
// Horizontal swipe to seek
|
// Horizontal swipe to seek
|
||||||
const swipeStartTime = useRef(0);
|
const swipeStartTime = useRef(0);
|
||||||
const panResponder = useRef(
|
const screenWRef = useRef(SCREEN_W);
|
||||||
PanResponder.create({
|
useEffect(() => {
|
||||||
onMoveShouldSetPanResponder: (_, gs) =>
|
screenWRef.current = SCREEN_W;
|
||||||
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
|
}, [SCREEN_W]);
|
||||||
onMoveShouldSetPanResponderCapture: (_, gs) =>
|
|
||||||
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
|
const panResponder = useMemo(
|
||||||
onPanResponderGrant: () => {
|
() =>
|
||||||
seekingRef.current = true;
|
PanResponder.create({
|
||||||
swipeStartTime.current = currentTimeRef.current;
|
onStartShouldSetPanResponderCapture: () => true,
|
||||||
},
|
onMoveShouldSetPanResponder: (_, gs) =>
|
||||||
onPanResponderMove: (_, gs) => {
|
Math.abs(gs.dx) > SWIPE_THRESHOLD &&
|
||||||
if (durationRef.current <= 0) return;
|
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
|
||||||
const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS;
|
onPanResponderGrant: () => {
|
||||||
const target = clamp(swipeStartTime.current + delta, 0, durationRef.current);
|
seekingRef.current = true;
|
||||||
setSeekLabel(formatDuration(Math.floor(target)));
|
swipeStartTime.current = currentTimeRef.current;
|
||||||
},
|
},
|
||||||
onPanResponderRelease: (_, gs) => {
|
onMoveShouldSetPanResponderCapture: (_, gs) =>
|
||||||
if (durationRef.current > 0) {
|
Math.abs(gs.dx) > SWIPE_THRESHOLD &&
|
||||||
const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS;
|
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
|
||||||
const target = clamp(swipeStartTime.current + delta, 0, durationRef.current);
|
onPanResponderMove: (_, gs) => {
|
||||||
videoRef.current?.seek(target);
|
if (durationRef.current <= 0) return;
|
||||||
setCurrentTime(target);
|
const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS;
|
||||||
}
|
const target = clamp(
|
||||||
seekingRef.current = false;
|
swipeStartTime.current + delta,
|
||||||
setSeekLabel(null);
|
0,
|
||||||
},
|
durationRef.current,
|
||||||
onPanResponderTerminate: () => {
|
);
|
||||||
seekingRef.current = false;
|
setSeekLabel(formatDuration(Math.floor(target)));
|
||||||
setSeekLabel(null);
|
},
|
||||||
},
|
onPanResponderRelease: (_, gs) => {
|
||||||
}),
|
if (durationRef.current > 0) {
|
||||||
).current;
|
const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS;
|
||||||
|
const target = clamp(
|
||||||
|
swipeStartTime.current + delta,
|
||||||
|
0,
|
||||||
|
durationRef.current,
|
||||||
|
);
|
||||||
|
videoRef.current?.seek(target);
|
||||||
|
setCurrentTime(target);
|
||||||
|
}
|
||||||
|
seekingRef.current = false;
|
||||||
|
setSeekLabel(null);
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
seekingRef.current = false;
|
||||||
|
setSeekLabel(null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||||
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
<View style={styles.card}>
|
||||||
{/* Media area */}
|
{/* Media area */}
|
||||||
<View style={[mediaDimensions, { position: "relative" }]}>
|
<View style={[mediaDimensions, { position: "relative" }]}>
|
||||||
{/* Video player — rendered first so it sits behind the thumbnail */}
|
{/* Video player — rendered first so it sits behind the thumbnail */}
|
||||||
@@ -194,11 +236,15 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
muted={muted}
|
muted={muted}
|
||||||
paused={paused}
|
paused={paused || seekingRef.current}
|
||||||
repeat
|
repeat
|
||||||
controls={false}
|
controls={false}
|
||||||
onReadyForDisplay={handleVideoReady}
|
onReadyForDisplay={handleVideoReady}
|
||||||
onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
|
onProgress={({
|
||||||
|
currentTime: ct,
|
||||||
|
seekableDuration: dur,
|
||||||
|
playableDuration: buf,
|
||||||
|
}) => {
|
||||||
if (!seekingRef.current) setCurrentTime(ct);
|
if (!seekingRef.current) setCurrentTime(ct);
|
||||||
if (dur > 0) setDuration(dur);
|
if (dur > 0) setDuration(dur);
|
||||||
setBuffered(buf);
|
setBuffered(buf);
|
||||||
@@ -219,12 +265,17 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Swipe gesture layer */}
|
{/* Swipe gesture layer */}
|
||||||
<View style={StyleSheet.absoluteFill} {...panResponder.panHandlers} />
|
<View
|
||||||
|
style={[StyleSheet.absoluteFill, { zIndex: 5 }]}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Seek time label */}
|
{/* Seek time label */}
|
||||||
{seekLabel && (
|
{seekLabel && (
|
||||||
<View style={styles.seekBadge}>
|
<View style={styles.seekBadge}>
|
||||||
<Text style={styles.seekText}>{seekLabel}</Text>
|
<Text style={styles.seekText}>
|
||||||
|
{seekLabel} / {formatDuration(durationRef.current)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -264,32 +315,39 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.progressLayer,
|
styles.progressLayer,
|
||||||
{ width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(0,174,236,0.25)" },
|
{
|
||||||
|
width: `${bufferedRatio * 100}%` as any,
|
||||||
|
backgroundColor: "rgba(0,174,236,0.25)",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.progressLayer,
|
styles.progressLayer,
|
||||||
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
|
{
|
||||||
|
width: `${progressRatio * 100}%` as any,
|
||||||
|
backgroundColor: "#00AEEC",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<View style={styles.info}>
|
<TouchableOpacity onPress={onPress} activeOpacity={0.85}>
|
||||||
<Text style={styles.title} numberOfLines={2}>
|
<View style={styles.info}>
|
||||||
{item.title}
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
</Text>
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Text style={styles.owner} numberOfLines={1}>
|
<Text style={styles.owner} numberOfLines={1}>
|
||||||
{item.owner?.name ?? ""}
|
{item.owner?.name ?? ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
@@ -320,7 +378,7 @@ const styles = StyleSheet.create({
|
|||||||
height: 28,
|
height: 28,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
zIndex: 3,
|
zIndex: 6,
|
||||||
},
|
},
|
||||||
seekBadge: {
|
seekBadge: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface Props {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveCard({
|
export const LiveCard = React.memo(function LiveCard({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
@@ -73,7 +73,7 @@ export function LiveCard({
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ export function LoginModal({ visible, onClose }: Props) {
|
|||||||
if (result.code === 86090) setStatus('scanned');
|
if (result.code === 86090) setStatus('scanned');
|
||||||
if (result.code === 0 && result.cookie) {
|
if (result.code === 0 && result.cookie) {
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
await login(result.cookie, '', '');
|
try {
|
||||||
setStatus('done');
|
await login(result.cookie, '', '');
|
||||||
// 登录后异步拉取用户头像和昵称
|
setStatus('done');
|
||||||
getUserInfo().then(info => setProfile(info.face, info.uname, String(info.mid))).catch(() => {});
|
// 登录后异步拉取用户头像和昵称
|
||||||
|
const info = await getUserInfo();
|
||||||
|
setProfile(info.face, info.uname, String(info.mid));
|
||||||
|
} catch {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, Image, StyleSheet, TouchableOpacity,
|
View, Text, Image, StyleSheet, TouchableOpacity,
|
||||||
Animated, PanResponder,
|
Animated, PanResponder, Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
@@ -9,6 +9,9 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useVideoStore } from '../store/videoStore';
|
import { useVideoStore } from '../store/videoStore';
|
||||||
import { proxyImageUrl } from '../utils/imageUrl';
|
import { proxyImageUrl } from '../utils/imageUrl';
|
||||||
|
|
||||||
|
const MINI_W = 160;
|
||||||
|
const MINI_H = 90;
|
||||||
|
|
||||||
export function MiniPlayer() {
|
export function MiniPlayer() {
|
||||||
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
|
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,14 +21,23 @@ export function MiniPlayer() {
|
|||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
|
|
||||||
onPanResponderRelease: () => {
|
|
||||||
pan.flattenOffset();
|
|
||||||
},
|
|
||||||
onPanResponderGrant: () => {
|
onPanResponderGrant: () => {
|
||||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||||
pan.setValue({ x: 0, y: 0 });
|
pan.setValue({ x: 0, y: 0 });
|
||||||
},
|
},
|
||||||
|
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
|
||||||
|
onPanResponderRelease: () => {
|
||||||
|
pan.flattenOffset();
|
||||||
|
// Clamp to screen bounds
|
||||||
|
const { width: sw, height: sh } = Dimensions.get('window');
|
||||||
|
const curX = (pan.x as any)._value;
|
||||||
|
const curY = (pan.y as any)._value;
|
||||||
|
const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX));
|
||||||
|
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
|
||||||
|
if (curX !== clampedX || curY !== clampedY) {
|
||||||
|
Animated.spring(pan, { toValue: { x: clampedX, y: clampedY }, useNativeDriver: false }).start();
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).current;
|
).current;
|
||||||
|
|
||||||
|
|||||||
@@ -41,94 +41,31 @@ function clamp(v: number, lo: number, hi: number) {
|
|||||||
return Math.max(lo, Math.min(hi, v));
|
return Math.max(lo, Math.min(hi, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodePvBuffer(buffer: ArrayBuffer): number[] {
|
function decodePvBuffer(bytes: Uint8Array): number[] {
|
||||||
const bytes = new Uint8Array(buffer);
|
// B站 pvdata 格式:packed uint16 little-endian,每个值 = 帧时间戳(单位:10ms)
|
||||||
const view = new DataView(buffer);
|
const result: number[] = [];
|
||||||
const timestamps: number[] = [];
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
|
||||||
let i = 0;
|
for (let i = 0; i + 1 < bytes.byteLength; i += 2) {
|
||||||
while (i < bytes.length) {
|
// uint16 值除以 100 转换为秒
|
||||||
const tag = bytes[i++];
|
result.push(view.getUint16(i, true) / 100);
|
||||||
const wireType = tag & 0x07;
|
|
||||||
const fieldNum = tag >> 3;
|
|
||||||
|
|
||||||
// 我们主要关心 repeated float32,通常 field 1 或直接数据
|
|
||||||
if (wireType === 5) {
|
|
||||||
// fixed32 / float32
|
|
||||||
if (i + 4 > bytes.length) break;
|
|
||||||
timestamps.push(view.getFloat32(i, true)); // little-endian
|
|
||||||
i += 4;
|
|
||||||
} else if (wireType === 2) {
|
|
||||||
// length-delimited → 进入子消息或 packed repeated
|
|
||||||
let len = 0;
|
|
||||||
let shift = 0;
|
|
||||||
while (true) {
|
|
||||||
if (i >= bytes.length) break;
|
|
||||||
const b = bytes[i++];
|
|
||||||
len |= (b & 0x7f) << shift;
|
|
||||||
shift += 7;
|
|
||||||
if (!(b & 0x80)) break;
|
|
||||||
}
|
|
||||||
const end = i + len;
|
|
||||||
// packed repeated float32 最常见情况:直接连续 float32
|
|
||||||
while (i + 4 <= end) {
|
|
||||||
timestamps.push(view.getFloat32(i, true));
|
|
||||||
i += 4;
|
|
||||||
}
|
|
||||||
// 如果不是 packed,也跳过
|
|
||||||
} else if (wireType === 0) {
|
|
||||||
// varint
|
|
||||||
while (i < bytes.length && bytes[i++] & 0x80);
|
|
||||||
} else if (wireType === 1) {
|
|
||||||
// fixed64
|
|
||||||
i += 8;
|
|
||||||
} else {
|
|
||||||
break; // 未知类型,停止
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉明显异常值(比如负数或极大值)
|
return result;
|
||||||
return timestamps.filter((t) => t >= 0 && t < 86400); // 视频不会超过24小时
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPvData(url: string) {
|
async function loadPvData(url: string): Promise<number[]> {
|
||||||
const realUrl = url.startsWith("//") ? `https:${url}` : url;
|
const realUrl = url.startsWith("//") ? `https:${url}` : url;
|
||||||
|
const cacheDir = new Directory(Paths.cache, "bili_pvdata");
|
||||||
try {
|
if (!cacheDir.exists) {
|
||||||
// 选择缓存目录下的一个子目录(避免污染根缓存)
|
await cacheDir.create({ intermediates: true });
|
||||||
const cacheDir = new Directory(Paths.cache, "bili_pvdata");
|
|
||||||
|
|
||||||
// 如果目录不存在,创建(intermediates: true 自动创建父目录)
|
|
||||||
if (!cacheDir.exists) {
|
|
||||||
await cacheDir.create({ intermediates: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载文件到这个目录(会自动用远程文件名,或你可以指定 File)
|
|
||||||
// 这里用 Directory 作为 destination,SDK 会从 URL 或 header 推导文件名
|
|
||||||
const downloadedFile: File = await File.downloadFileAsync(
|
|
||||||
realUrl,
|
|
||||||
cacheDir,
|
|
||||||
{
|
|
||||||
headers: HEADERS,
|
|
||||||
idempotent: true, // 如果文件已存在,覆盖(避免重复下载失败)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log("Downloaded to:", downloadedFile.uri);
|
|
||||||
// 读取为 base64(如果你原来的 decodeFloats/decodePvBuffer 用 base64)
|
|
||||||
// const base64 = await downloadedFile.base64();
|
|
||||||
// 更好:直接读 binary 为 Uint8Array,然后转 ArrayBuffer
|
|
||||||
const bytes: Uint8Array = await downloadedFile.bytes();
|
|
||||||
const nums = new Uint16Array(
|
|
||||||
bytes.buffer,
|
|
||||||
bytes.byteOffset,
|
|
||||||
bytes.byteLength / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
return nums;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("loadPvData failed:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
const downloadedFile = await File.downloadFileAsync(realUrl, cacheDir, {
|
||||||
|
headers: HEADERS,
|
||||||
|
idempotent: true,
|
||||||
|
});
|
||||||
|
const bytes: Uint8Array = await downloadedFile.bytes();
|
||||||
|
return decodePvBuffer(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFrameIdx(timestamps: number[], seekTime: number): number {
|
function findFrameIdx(timestamps: number[], seekTime: number): number {
|
||||||
@@ -231,13 +168,9 @@ export function NativeVideoPlayer({
|
|||||||
if (shotData?.image?.length) {
|
if (shotData?.image?.length) {
|
||||||
setShots(shotData);
|
setShots(shotData);
|
||||||
if (shotData.pvdata) {
|
if (shotData.pvdata) {
|
||||||
try {
|
loadPvData(shotData.pvdata)
|
||||||
loadPvData(shotData.pvdata).then((r) => {
|
.then((r) => setShotTimestamps(r))
|
||||||
setShotTimestamps(r);
|
.catch(() => setShotTimestamps([]));
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setShotTimestamps([]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -351,10 +284,11 @@ export function NativeVideoPlayer({
|
|||||||
|
|
||||||
// Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation
|
// Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation
|
||||||
const seekTime = touchRatio * duration;
|
const seekTime = touchRatio * duration;
|
||||||
const frameIdx =
|
const rawIdx =
|
||||||
shotTimestamps.length > 0
|
shotTimestamps.length > 0
|
||||||
? findFrameIdx(shotTimestamps, seekTime)
|
? findFrameIdx(shotTimestamps, seekTime)
|
||||||
: Math.floor(touchRatio * (totalFrames - 1));
|
: Math.floor(touchRatio * (totalFrames - 1));
|
||||||
|
const frameIdx = clamp(rawIdx, 0, totalFrames - 1);
|
||||||
|
|
||||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||||
const local = frameIdx % framesPerSheet;
|
const local = frameIdx % framesPerSheet;
|
||||||
@@ -421,7 +355,11 @@ export function NativeVideoPlayer({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
controls={false}
|
controls={false}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
|
onProgress={({
|
||||||
|
currentTime: ct,
|
||||||
|
seekableDuration: dur,
|
||||||
|
playableDuration: buf,
|
||||||
|
}) => {
|
||||||
setCurrentTime(ct);
|
setCurrentTime(ct);
|
||||||
if (dur > 0) setDuration(dur);
|
if (dur > 0) setDuration(dur);
|
||||||
setBuffered(buf);
|
setBuffered(buf);
|
||||||
@@ -505,14 +443,20 @@ export function NativeVideoPlayer({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.trackLayer,
|
styles.trackLayer,
|
||||||
{ width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(255,255,255,0.35)" },
|
{
|
||||||
|
width: `${bufferedRatio * 100}%` as any,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.35)",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{/* Played: accent color on top */}
|
{/* Played: accent color on top */}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.trackLayer,
|
styles.trackLayer,
|
||||||
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
|
{
|
||||||
|
width: `${progressRatio * 100}%` as any,
|
||||||
|
backgroundColor: "#00AEEC",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface Props {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoCard({ item, onPress }: Props) {
|
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.card}
|
style={styles.card}
|
||||||
@@ -55,7 +55,7 @@ export function VideoCard({ item, onPress }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
|
|||||||
154
feature.md
Normal file
154
feature.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# 代码审查:优化建议与 Bug 清单
|
||||||
|
|
||||||
|
## 严重 Bug
|
||||||
|
|
||||||
|
### 1. ~~useComments 竞态条件~~ [已修复]
|
||||||
|
- **文件**: `hooks/useComments.ts`
|
||||||
|
- **问题**: `load()` 依赖 `[aid, page, loading, hasMore]`,`setPage(p => p + 1)` 触发新的 load 回调,可能重复请求或跳页
|
||||||
|
- **修复**: 改用 `pageRef` + `loadingRef` 避免依赖循环
|
||||||
|
|
||||||
|
### 2. ~~LoginModal 错误处理缺失~~ [已修复]
|
||||||
|
- **文件**: `components/LoginModal.tsx`
|
||||||
|
- **问题**: `login(result.cookie, '', '')` 传入空 uid/username;`getUserInfo()` 调用无错误处理
|
||||||
|
- **修复**: 添加 try-catch 包裹登录+拉取用户信息流程
|
||||||
|
|
||||||
|
### 3. ~~NativeVideoPlayer loadPvData 未捕获 Promise 异常~~ [已修复]
|
||||||
|
- **文件**: `components/NativeVideoPlayer.tsx`
|
||||||
|
- **问题**: `loadPvData().then(...)` 没有 `.catch()`,且原来的 try-catch 无法捕获异步 Promise
|
||||||
|
- **修复**: 改为 `.then().catch()` 链式调用
|
||||||
|
|
||||||
|
### 4. BigVideoCard PanResponder 闭包陈旧
|
||||||
|
- **文件**: `components/BigVideoCard.tsx`
|
||||||
|
- **问题**: `useMemo` 依赖为 `[]`,但内部使用 `currentTimeRef`、`durationRef`、`screenWRef`,重构时容易引入 bug
|
||||||
|
- **状态**: 当前使用 ref 所以不会出错,但属于脆弱模式
|
||||||
|
|
||||||
|
## 性能问题
|
||||||
|
|
||||||
|
### 5. ~~index.tsx renderItem 未稳定~~ [已修复]
|
||||||
|
- **文件**: `app/index.tsx`
|
||||||
|
- **问题**: `renderItem` 依赖 `[visibleBigKey]`,每次可见项变化都重建回调
|
||||||
|
- **修复**: 改用 `visibleBigKeyRef` + 空依赖 `useCallback`
|
||||||
|
|
||||||
|
### 6. BigVideoCard 视频预加载无取消机制
|
||||||
|
- **文件**: `components/BigVideoCard.tsx`
|
||||||
|
- **问题**: 快速滑动时,所有可见卡片同时发起 `getPlayUrl` 请求,无取消/去抖
|
||||||
|
- **修复**: 增加可见性停留阈值或 AbortController
|
||||||
|
|
||||||
|
### 7. DanmakuList 动画值频繁创建
|
||||||
|
- **文件**: `components/DanmakuList.tsx`
|
||||||
|
- **问题**: 每次 drip interval 都创建新的 `Animated.Value`,弹幕密集时影响性能
|
||||||
|
- **修复**: 使用对象池复用 Animated.Value
|
||||||
|
|
||||||
|
### 8. LivePulse 动画堆叠
|
||||||
|
- **文件**: `components/LivePulse.tsx`
|
||||||
|
- **问题**: 组件快速卸载/重新挂载时,`Animated.loop()` 动画可能堆叠
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 9. 缺少全局错误边界
|
||||||
|
- **文件**: `app/_layout.tsx`
|
||||||
|
- **问题**: 没有 React Error Boundary,任何组件抛异常会导致白屏
|
||||||
|
- **修复**: 在根布局包裹 ErrorBoundary 组件
|
||||||
|
|
||||||
|
### 10. ~~WBI Keys 缓存无过期~~ [已修复]
|
||||||
|
- **文件**: `services/bilibili.ts`
|
||||||
|
- **问题**: `wbiKeys` 模块级变量缓存后永不过期,B站 WBI 密钥每日更换
|
||||||
|
- **修复**: 添加 12 小时 TTL,过期后自动刷新;网络错误时回退到陈旧缓存
|
||||||
|
|
||||||
|
### 11. ~~getWbiKeys 无容错~~ [已修复]
|
||||||
|
- **文件**: `services/bilibili.ts`
|
||||||
|
- **问题**: 直接访问 `res.data.data.wbi_img`,结构不匹配时直接崩溃
|
||||||
|
- **修复**: 添加可选链检查 + try-catch + 陈旧缓存回退
|
||||||
|
|
||||||
|
### 12. ~~Cookie 解析脆弱~~ [已修复]
|
||||||
|
- **文件**: `services/bilibili.ts`
|
||||||
|
- **问题**: `split(';')[0].replace('SESSDATA=', '')` 假设 SESSDATA 是第一个 cookie 属性
|
||||||
|
- **修复**: 改为 `.find()` 查找包含 `SESSDATA=` 的 part
|
||||||
|
|
||||||
|
### 13. ~~useVideoDetail 静默吞错~~ [已修复]
|
||||||
|
- **文件**: `hooks/useVideoDetail.ts`
|
||||||
|
- **问题**: `.catch(() => {})` 吞掉重新获取播放数据时的错误,用户无感知
|
||||||
|
- **修复**: 改为 `console.warn` 输出
|
||||||
|
|
||||||
|
## 代码质量
|
||||||
|
|
||||||
|
### 14. ~~残留调试代码~~ [已修复]
|
||||||
|
- **文件**: `components/NativeVideoPlayer.tsx`
|
||||||
|
- **问题**: `console.log(r, "ddddddddddddd")` 和未使用的 `axios` 导入
|
||||||
|
- **修复**: 已移除
|
||||||
|
|
||||||
|
### 15. 类型安全问题
|
||||||
|
- **文件**: `app/index.tsx`
|
||||||
|
- **问题**: `router.push(\`/video/${bvid}\` as any)` 使用 `as any` 绕过类型检查
|
||||||
|
- **修复**: 正确定义路由类型
|
||||||
|
|
||||||
|
### 16. 未使用的导入
|
||||||
|
- **文件**: `components/VideoPlayer.tsx`
|
||||||
|
- **问题**: `Paths` 从 `expo-file-system` 导入但未使用
|
||||||
|
|
||||||
|
### 17. 魔法数字散落
|
||||||
|
- **文件**: 多处
|
||||||
|
- **问题**: `SWIPE_SECONDS = 90`、`HIDE_DELAY = 3000`、`DRIP_INTERVAL = 250` 等硬编码
|
||||||
|
- **修复**: 统一到 constants 文件
|
||||||
|
|
||||||
|
## API 使用
|
||||||
|
|
||||||
|
### 18. 无请求去重
|
||||||
|
- **文件**: `services/bilibili.ts`
|
||||||
|
- **问题**: BigVideoCard 和 NativeVideoPlayer 可能同时为同一视频发起 `getPlayUrl` 请求
|
||||||
|
- **修复**: 实现请求去重(相同参数复用 Promise)
|
||||||
|
|
||||||
|
### 19. 无请求超时重试
|
||||||
|
- **文件**: `services/bilibili.ts`
|
||||||
|
- **问题**: 设置了 `timeout: 10000` 但无重试逻辑,弱网下直接失败
|
||||||
|
|
||||||
|
## UI/UX
|
||||||
|
|
||||||
|
### 20. ~~MiniPlayer 拖拽无边界~~ [已修复]
|
||||||
|
- **文件**: `components/MiniPlayer.tsx`
|
||||||
|
- **问题**: 拖拽可将迷你播放器移出屏幕外,无边界限制
|
||||||
|
- **修复**: 释放时 clamp 到屏幕范围内,超出部分用 spring 动画弹回
|
||||||
|
|
||||||
|
### 21. 无网络状态检测
|
||||||
|
- **问题**: 离线时所有 API 静默失败,用户无反馈
|
||||||
|
- **修复**: 使用 `@react-native-community/netinfo` 检测网络并提示
|
||||||
|
|
||||||
|
### 22. BigVideoCard 预加载无 Loading 态
|
||||||
|
- **问题**: 视频 URL 加载期间画面冻结,无骨架屏或加载动画
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
### 23. SESSDATA 明文存储
|
||||||
|
- **文件**: `services/bilibili.ts` / `store/authStore.ts`
|
||||||
|
- **问题**: Cookie 存储在 AsyncStorage(明文),设备被攻破可泄露
|
||||||
|
- **修复**: 使用 `expo-secure-store` 存储敏感凭证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复进度
|
||||||
|
|
||||||
|
| 优先级 | 项目 | 状态 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **P0** | #1 竞态条件 | 已修复 |
|
||||||
|
| **P0** | #3 Promise 未捕获 | 已修复 |
|
||||||
|
| **P0** | #14 调试代码 | 已修复 |
|
||||||
|
| **P0** | #10 WBI 过期 | 已修复 |
|
||||||
|
| **P1** | #2 登录错误处理 | 已修复 |
|
||||||
|
| **P1** | #5 renderItem 稳定化 | 已修复 |
|
||||||
|
| **P1** | #11 getWbiKeys 容错 | 已修复 |
|
||||||
|
| **P1** | #12 Cookie 解析 | 已修复 |
|
||||||
|
| **P1** | #13 静默吞错 | 已修复 |
|
||||||
|
| **P2** | #20 MiniPlayer 边界 | 已修复 |
|
||||||
|
| **P1** | #9 错误边界 | 待修复 |
|
||||||
|
| **P2** | #6 预加载取消 | 待修复 |
|
||||||
|
| **P2** | #18 请求去重 | 待修复 |
|
||||||
|
| **P2** | #21 网络检测 | 待修复 |
|
||||||
|
| **P3** | #4 闭包陈旧 | 暂不修复(ref模式可用) |
|
||||||
|
| **P3** | #7 动画池化 | 待修复 |
|
||||||
|
| **P3** | #8 动画堆叠 | 待修复 |
|
||||||
|
| **P3** | #15 类型安全 | 待修复 |
|
||||||
|
| **P3** | #16 未使用导入 | 待修复 |
|
||||||
|
| **P3** | #17 魔法数字 | 待修复 |
|
||||||
|
| **P3** | #19 超时重试 | 待修复 |
|
||||||
|
| **P3** | #22 Loading 态 | 待修复 |
|
||||||
|
| **P3** | #23 安全存储 | 待修复 |
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { getComments } from '../services/bilibili';
|
import { getComments } from '../services/bilibili';
|
||||||
import type { Comment } from '../services/types';
|
import type { Comment } from '../services/types';
|
||||||
|
|
||||||
export function useComments(aid: number) {
|
export function useComments(aid: number) {
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
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 pageRef = useRef(1);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (loading || !hasMore || !aid) return;
|
if (loadingRef.current || !hasMore || !aid) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getComments(aid, page);
|
const data = await getComments(aid, pageRef.current);
|
||||||
if (data.length === 0) { setHasMore(false); return; }
|
if (data.length === 0) { setHasMore(false); return; }
|
||||||
setComments(prev => [...prev, ...data]);
|
setComments(prev => [...prev, ...data]);
|
||||||
setPage(p => p + 1);
|
pageRef.current += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load comments', e);
|
console.error('Failed to load comments', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [aid, page, loading, hasMore]);
|
}, [aid, hasMore]);
|
||||||
|
|
||||||
return { comments, loading, hasMore, load };
|
return { comments, loading, hasMore, load };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function useVideoDetail(bvid: string) {
|
|||||||
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
|
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cidRef.current) {
|
if (cidRef.current) {
|
||||||
fetchPlayData(cidRef.current, 120, true).catch(() => {});
|
fetchPlayData(cidRef.current, 120, true).catch((e) => {
|
||||||
|
console.warn('Failed to refresh quality list after login change:', e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-native": "0.83.2",
|
"react-native": "0.83.2",
|
||||||
|
"react-native-pager-view": "8.0.0",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-video": "^6.19.0",
|
"react-native-video": "^6.19.0",
|
||||||
@@ -7841,6 +7842,16 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-pager-view": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-oAwlWT1lhTkIs9HhODnjNNl/owxzn9DP1MbP+az6OTUdgbmzA16Up83sBH8NRKwrH8rNm7iuWnX1qMqiiWOLhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-safe-area-context": {
|
"node_modules/react-native-safe-area-context": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-native": "0.83.2",
|
"react-native": "0.83.2",
|
||||||
|
"react-native-pager-view": "8.0.0",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-video": "^6.19.0",
|
"react-native-video": "^6.19.0",
|
||||||
|
|||||||
@@ -61,16 +61,28 @@ api.interceptors.request.use(async (config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// WBI key cache (rotates ~daily, reuse within process lifetime)
|
// WBI key cache (rotates ~daily)
|
||||||
let wbiKeys: { imgKey: string; subKey: string } | null = null;
|
let wbiKeys: { imgKey: string; subKey: string } | null = null;
|
||||||
|
let wbiKeysTimestamp = 0;
|
||||||
|
const WBI_KEYS_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
|
|
||||||
async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> {
|
async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> {
|
||||||
if (wbiKeys) return wbiKeys;
|
if (wbiKeys && Date.now() - wbiKeysTimestamp < WBI_KEYS_TTL) return wbiKeys;
|
||||||
const res = await api.get('/x/web-interface/nav');
|
try {
|
||||||
const { img_url, sub_url } = res.data.data.wbi_img;
|
const res = await api.get('/x/web-interface/nav');
|
||||||
const extract = (url: string) => url.split('/').pop()!.replace(/\.\w+$/, '');
|
const wbiImg = res.data?.data?.wbi_img;
|
||||||
wbiKeys = { imgKey: extract(img_url), subKey: extract(sub_url) };
|
if (!wbiImg?.img_url || !wbiImg?.sub_url) {
|
||||||
return wbiKeys;
|
if (wbiKeys) return wbiKeys; // fallback to stale cache
|
||||||
|
throw new Error('Failed to get WBI keys: missing wbi_img data');
|
||||||
|
}
|
||||||
|
const extract = (url: string) => url.split('/').pop()!.replace(/\.\w+$/, '');
|
||||||
|
wbiKeys = { imgKey: extract(wbiImg.img_url), subKey: extract(wbiImg.sub_url) };
|
||||||
|
wbiKeysTimestamp = Date.now();
|
||||||
|
return wbiKeys;
|
||||||
|
} catch (e) {
|
||||||
|
if (wbiKeys) return wbiKeys; // fallback to stale cache on network error
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
|
export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
|
||||||
@@ -157,9 +169,12 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
|
|||||||
cookie = res.headers['x-sessdata'] as string | undefined;
|
cookie = res.headers['x-sessdata'] as string | undefined;
|
||||||
} else {
|
} else {
|
||||||
const setCookie = res.headers['set-cookie'];
|
const setCookie = res.headers['set-cookie'];
|
||||||
const match = setCookie?.find((c: string) => c.includes('SESSDATA'));
|
const match = setCookie?.find((c: string) => c.includes('SESSDATA='));
|
||||||
if (match) {
|
if (match) {
|
||||||
cookie = match.split(';')[0].replace('SESSDATA=', '');
|
const sessPart = match.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
|
||||||
|
if (sessPart) {
|
||||||
|
cookie = sessPart.trim().replace('SESSDATA=', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user