bug修改

This commit is contained in:
Developer
2026-05-12 20:27:30 +08:00
parent b0929a8094
commit 53c67079a1
23 changed files with 1026 additions and 387 deletions

View File

@@ -6,6 +6,8 @@ import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
import { useDownloadStore } from '../store/downloadStore';
import { useSettingsStore } from '../store/settingsStore';
import { usePlayProgressStore } from '../store/playProgressStore';
import { initMiniExclusion } from '../store/miniExclusion';
import { useTheme } from '../utils/theme';
import { MiniPlayer } from '../components/MiniPlayer';
import { LiveMiniPlayer } from '../components/LiveMiniPlayer';
@@ -35,6 +37,8 @@ function RootLayout() {
restore();
loadDownloads();
restoreSettings();
usePlayProgressStore.getState().hydrate();
initMiniExclusion();
}, []);
if (!fontsLoaded) return null;

View File

@@ -36,11 +36,11 @@ import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard";
import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
import { useTheme } from "../utils/theme";
import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore";
import type { LiveRoom } from "../services/types";
const HEADER_H = 44;
@@ -69,8 +69,7 @@ const LIVE_AREAS = [
export default function HomeScreen() {
const router = useRouter();
const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
const { pages, loading, refreshing, load, refresh } = useVideoList();
const {
rooms,
loading: liveLoading,
@@ -85,8 +84,7 @@ export default function HomeScreen() {
const [liveAreaId, setLiveAreaId] = useState(0);
const theme = useTheme();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const rows = useMemo(() => toListRows(pages), [pages]);
const pagerRef = useRef<PagerView>(null);
const hotListRef = useRef<FlatList>(null);
@@ -97,57 +95,113 @@ export default function HomeScreen() {
const bigRow = viewableItems.find(
(v) => v.item && (v.item as ListRow).type === "big",
);
setVisibleBigKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
useVisibleBigKeyStore.getState().setKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
},
).current;
const scrollY = useRef(new Animated.Value(0)).current;
// 滚动累计阈值:方向反转后需累计滚动该距离才触发显隐切换
const SCROLL_THRESHOLD = 40;
// 显隐过渡时长
const HEADER_ANIM_MS = 100;
const headerTranslate = scrollY.interpolate({
const headerOffset = useRef(new Animated.Value(0)).current; // 0 = 显示HEADER_H = 隐藏
const liveHeaderOffset = useRef(new Animated.Value(0)).current;
const headerTranslate = headerOffset.interpolate({
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const headerOpacity = scrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
const headerOpacity = headerOffset.interpolate({
inputRange: [0, HEADER_H * 0.6, HEADER_H],
outputRange: [1, 1, 0],
extrapolate: "clamp",
});
// 直播列表也共用同一个 scrollY
const liveScrollY = useRef(new Animated.Value(0)).current;
const liveHeaderTranslate = liveScrollY.interpolate({
const liveHeaderTranslate = liveHeaderOffset.interpolate({
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const liveHeaderOpacity = liveScrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
const liveHeaderOpacity = liveHeaderOffset.interpolate({
inputRange: [0, HEADER_H * 0.6, HEADER_H],
outputRange: [1, 1, 0],
extrapolate: "clamp",
});
const hotScrollState = useRef({ lastY: 0, acc: 0, dir: 0, hidden: false }).current;
const liveScrollState = useRef({ lastY: 0, acc: 0, dir: 0, hidden: false }).current;
const animateHeader = useCallback(
(offset: Animated.Value, hide: boolean) => {
Animated.timing(offset, {
toValue: hide ? HEADER_H : 0,
duration: HEADER_ANIM_MS,
useNativeDriver: true,
}).start();
},
[],
);
const updateHeaderForScroll = useCallback(
(
y: number,
state: { lastY: number; acc: number; dir: number; hidden: boolean },
offset: Animated.Value,
) => {
// 顶部强制显示
if (y <= 0) {
if (state.hidden) {
state.hidden = false;
animateHeader(offset, false);
}
state.lastY = 0;
state.acc = 0;
state.dir = 0;
return;
}
const dy = y - state.lastY;
state.lastY = y;
if (Math.abs(dy) < 1) return;
const dir = dy > 0 ? 1 : -1; // 1=向下滚(隐藏),-1=向上滚(显示)
if (dir !== state.dir) {
state.dir = dir;
state.acc = 0;
}
state.acc += Math.abs(dy);
if (state.acc < SCROLL_THRESHOLD) return;
const shouldHide = dir === 1;
if (shouldHide !== state.hidden) {
state.hidden = shouldHide;
animateHeader(offset, shouldHide);
}
state.acc = 0;
},
[animateHeader],
);
useEffect(() => {
load();
}, []);
const onScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
}),
[],
const onScroll = useCallback(
(e: any) =>
updateHeaderForScroll(
e.nativeEvent.contentOffset.y,
hotScrollState,
headerOffset,
),
[updateHeaderForScroll],
);
const onLiveScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
useNativeDriver: true,
}),
[],
const onLiveScroll = useCallback(
(e: any) =>
updateHeaderForScroll(
e.nativeEvent.contentOffset.y,
liveScrollState,
liveHeaderOffset,
),
[updateHeaderForScroll],
);
const handleTabPress = useCallback(
@@ -195,41 +249,15 @@ export default function HomeScreen() {
[liveAreaId, liveLoad],
);
const visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey;
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
if (row.type === "big") {
return (
<BigVideoCard
item={row.item}
isVisible={visibleBigKeyRef.current === row.item.bvid}
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
/>
);
}
if (row.type === "live") {
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
isLivePulse
item={row.left}
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View>
{row.right && (
<View style={styles.rightCol}>
<LiveCard
isLivePulse
item={row.right}
onPress={() => router.push(`/live/${row.right!.roomid}` as any)}
/>
</View>
)}
</View>
);
}
const right = row.right;
return (
<View style={styles.row}>
@@ -303,12 +331,10 @@ export default function HomeScreen() {
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
keyExtractor={(row: any, index: number) =>
keyExtractor={(row: any) =>
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
}
contentContainerStyle={{
paddingTop: insets.top + NAV_H + 6,
@@ -324,7 +350,6 @@ export default function HomeScreen() {
}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
extraData={visibleBigKey}
viewabilityConfig={VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChangedRef}
ListFooterComponent={
@@ -603,7 +628,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: "#f0f0f0",
},
areaTabActive: {
backgroundColor: "#00AEEC",

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "react";
import {
View,
Text,
@@ -6,6 +6,8 @@ import {
StyleSheet,
TouchableOpacity,
ActivityIndicator,
useWindowDimensions,
InteractionManager,
} from "react-native";
import { Image } from "expo-image";
import { SafeAreaView } from "react-native-safe-area-context";
@@ -22,6 +24,7 @@ import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { DownloadSheet } from "../../components/DownloadSheet";
import { VideoDetailSkeleton } from "../../components/VideoDetailSkeleton";
import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore";
@@ -36,6 +39,14 @@ export default function VideoDetailScreen() {
useLiveStore.getState().clearLive();
}, []);
// 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过
const SKELETON_MIN_MS = 600;
const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false);
useEffect(() => {
const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS);
return () => clearTimeout(t);
}, []);
const {
video,
playData,
@@ -43,6 +54,7 @@ export default function VideoDetailScreen() {
qualities,
currentQn,
changeQuality,
initialTime,
} = useVideoDetail(bvid as string);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const {
@@ -67,20 +79,36 @@ export default function VideoDetailScreen() {
load: loadRelated,
} = useRelatedVideos(bvid as string);
useEffect(() => { loadRelated(); }, []);
// 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程
useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => {
loadRelated();
});
return () => handle.cancel();
}, []);
useEffect(() => {
if (video?.aid) loadComments();
if (!video?.aid) return;
const handle = InteractionManager.runAfterInteractions(() => {
loadComments();
});
return () => handle.cancel();
}, [video?.aid, commentSort]);
useEffect(() => {
if (!video?.cid) return;
getDanmaku(video.cid).then(setDanmakus).catch(() => {});
const handle = InteractionManager.runAfterInteractions(() => {
getDanmaku(video.cid!).then(setDanmakus).catch(() => {});
});
return () => handle.cancel();
}, [video?.cid]);
useEffect(() => {
if (!video?.owner?.mid) return;
getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {});
const handle = InteractionManager.runAfterInteractions(() => {
getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {});
});
return () => handle.cancel();
}, [video?.owner?.mid]);
return (
@@ -107,6 +135,7 @@ export default function VideoDetailScreen() {
cid={video?.cid}
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
initialTime={initialTime}
/>
<DownloadSheet
visible={showDownload}
@@ -139,9 +168,9 @@ export default function VideoDetailScreen() {
)}
{/* Tab content */}
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
{videoLoading || !video || !minSkeletonElapsed ? (
<VideoDetailSkeleton />
) : (
<>
{tab === "intro" && (
<FlatList<import("../../services/types").VideoItem>
@@ -306,7 +335,7 @@ export default function VideoDetailScreen() {
style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]}
/>
</>
) : null}
)}
</SafeAreaView>
);
}
@@ -322,17 +351,33 @@ function SeasonSection({
onEpisodePress: (bvid: string) => void;
}) {
const theme = useTheme();
const { width: screenW } = useWindowDimensions();
const episodes = season.sections?.[0]?.episodes ?? [];
const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid);
const listRef = useRef<FlatList>(null);
// 初次渲染先隐身scrollToOffset 完成后再显示,彻底消除"先 0 再跳"的可见闪烁
const [ready, setReady] = useState(currentIndex <= 0);
useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return;
const t = setTimeout(() => {
listRef.current?.scrollToIndex({ index: currentIndex, viewPosition: 0.5, animated: false });
}, 200);
return () => clearTimeout(t);
}, [currentIndex, episodes.length]);
// 计算让当前集水平居中的初始 contentOffset
const initialOffset = useMemo(() => {
if (currentIndex <= 0) return 0;
const ITEM_WIDTH = 120;
const STEP = 130; // 120 + 10 gap
const PADDING = 12;
const itemCenter = PADDING + currentIndex * STEP + ITEM_WIDTH / 2;
return Math.max(0, itemCenter - screenW / 2);
}, [currentIndex, screenW]);
// 内容布局完成时getItemLayout 同步即知尺寸content size 一就绪就触发)
// 同步 scrollToOffset 后立刻显示,避免任何中间帧
const handleContentSizeChange = useCallback(() => {
if (ready) return;
if (currentIndex > 0 && initialOffset > 0) {
listRef.current?.scrollToOffset({ offset: initialOffset, animated: false });
}
// 等到下一帧再显示,确保滚动已落地
requestAnimationFrame(() => setReady(true));
}, [ready, currentIndex, initialOffset]);
return (
<View style={[styles.seasonBox, { borderTopColor: theme.border, backgroundColor: theme.card }]}>
@@ -349,7 +394,10 @@ function SeasonSection({
keyExtractor={(ep) => ep.bvid}
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })}
contentOffset={{ x: initialOffset, y: 0 }}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={() => {}}
style={{ opacity: ready ? 1 : 0 }}
renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid;
return (