mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
bug修改
This commit is contained in:
@@ -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;
|
||||
|
||||
152
app/index.tsx
152
app/index.tsx
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user