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 (
|
||||
|
||||
@@ -24,7 +24,9 @@ import { coverImageUrl } from "../utils/imageUrl";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { useLiveStore } from "../store/liveStore";
|
||||
import { usePlayUrlCache } from "../store/playUrlCache";
|
||||
import { formatCount, formatDuration } from "../utils/format";
|
||||
import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore";
|
||||
import type { VideoItem } from "../services/types";
|
||||
|
||||
const HEADERS = {
|
||||
@@ -45,17 +47,16 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
isVisible: boolean;
|
||||
isScrolling?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
item,
|
||||
isVisible,
|
||||
isScrolling,
|
||||
onPress,
|
||||
}: Props) {
|
||||
const isVisible = useVisibleBigKeyStore((s) => s.key) === item.bvid;
|
||||
const { width: SCREEN_W } = useWindowDimensions();
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
const liveActive = useLiveStore(s => s.isActive);
|
||||
@@ -79,6 +80,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
const durationRef = useRef(0);
|
||||
const seekingRef = useRef(false);
|
||||
const [seekLabel, setSeekLabel] = useState<string | null>(null);
|
||||
const lastProgressUpdate = useRef(0);
|
||||
|
||||
// Reset video state when the item changes
|
||||
useEffect(() => {
|
||||
@@ -98,6 +100,18 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// 命中缓存(5min TTL)直接复用
|
||||
const cached = usePlayUrlCache.getState().get(item.bvid, 16);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
if (cached.playData.dash) {
|
||||
setIsDash(true);
|
||||
setVideoUrl(cached.mpdUri ?? cached.playData.dash.video[0]?.baseUrl);
|
||||
} else {
|
||||
setVideoUrl(cached.playData.durl?.[0]?.url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let cid = item.cid;
|
||||
if (!cid) {
|
||||
const detail = await getVideoDetail(item.bvid);
|
||||
@@ -113,13 +127,22 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
if (playData.dash) {
|
||||
if (!cancelled) setIsDash(true);
|
||||
try {
|
||||
const mpdUri = await buildDashMpdUri(playData, 16);
|
||||
if (!cancelled) setVideoUrl(mpdUri);
|
||||
const mpdUri = await buildDashMpdUri(playData, 16, item.bvid);
|
||||
if (!cancelled) {
|
||||
setVideoUrl(mpdUri);
|
||||
usePlayUrlCache.getState().set(item.bvid, 16, { playData, mpdUri });
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setVideoUrl(playData.dash.video[0]?.baseUrl);
|
||||
if (!cancelled) {
|
||||
setVideoUrl(playData.dash.video[0]?.baseUrl);
|
||||
usePlayUrlCache.getState().set(item.bvid, 16, { playData });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) setVideoUrl(playData.durl?.[0]?.url);
|
||||
if (!cancelled) {
|
||||
setVideoUrl(playData.durl?.[0]?.url);
|
||||
usePlayUrlCache.getState().set(item.bvid, 16, { playData });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("BigVideoCard: failed to load play URL", e);
|
||||
@@ -252,8 +275,14 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
if (!seekingRef.current) setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
currentTimeRef.current = ct;
|
||||
if (dur > 0) durationRef.current = dur;
|
||||
if (seekingRef.current) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate.current < 450) return;
|
||||
lastProgressUpdate.current = now;
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0 && Math.abs(dur - duration) > 1) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,10 @@ interface Props {
|
||||
|
||||
const LANE_COUNT = 5;
|
||||
const LANE_H = 28;
|
||||
// 同屏弹幕上限(原 30,提至 60)
|
||||
const MAX_ACTIVE = 60;
|
||||
// activated 集合阈值,触达后整体清零防止内存膨胀
|
||||
const ACTIVATED_LIMIT = 1000;
|
||||
|
||||
interface ActiveDanmaku {
|
||||
id: string;
|
||||
@@ -73,7 +77,10 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
|
||||
|
||||
const newItems: ActiveDanmaku[] = [];
|
||||
|
||||
if (activated.current.size > 200) activated.current.clear(); // prevent memory leak
|
||||
if (activated.current.size > ACTIVATED_LIMIT) {
|
||||
activated.current.clear(); // prevent memory leak
|
||||
idCounter.current = 0;
|
||||
}
|
||||
for (const item of candidates) {
|
||||
const key = `${item.time}_${item.text}`;
|
||||
activated.current.add(key);
|
||||
@@ -124,8 +131,7 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
|
||||
if (newItems.length > 0) {
|
||||
setActiveDanmakus(prev => {
|
||||
const combined = [...prev, ...newItems];
|
||||
// Cap at 30 simultaneous danmakus
|
||||
return combined.slice(Math.max(0, combined.length - 30));
|
||||
return combined.slice(Math.max(0, combined.length - MAX_ACTIVE));
|
||||
});
|
||||
}
|
||||
}, [currentTime, visible, danmakus, pickLane, screenWidth]);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
PanResponder,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
@@ -15,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLiveStore } from '../store/liveStore';
|
||||
import { useVideoStore } from '../store/videoStore';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
import { useMiniDrag } from '../hooks/useMiniDrag';
|
||||
|
||||
const MINI_W = 160;
|
||||
const MINI_H = 90;
|
||||
@@ -25,70 +24,29 @@ const LIVE_HEADERS = {
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
|
||||
function snapRelease(
|
||||
pan: Animated.ValueXY,
|
||||
curX: number,
|
||||
curY: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
) {
|
||||
const snapRight = 0;
|
||||
const snapLeft = -(sw - MINI_W - 24);
|
||||
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
|
||||
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
|
||||
Animated.spring(pan, {
|
||||
toValue: { x: snapX, y: clampedY },
|
||||
useNativeDriver: false,
|
||||
tension: 120,
|
||||
friction: 10,
|
||||
}).start();
|
||||
}
|
||||
|
||||
export function LiveMiniPlayer() {
|
||||
const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore();
|
||||
const videoMiniActive = useVideoStore(s => s.isActive);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pan = useRef(new Animated.ValueXY()).current;
|
||||
const isDragging = useRef(false);
|
||||
// 关闭时先把 Video 暂停一帧,让 native 释放连接,再 unmount
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||
const storeRef = useRef({ roomId, clearLive, router });
|
||||
storeRef.current = { roomId, clearLive, router };
|
||||
// 切换到不同直播间时重置 paused
|
||||
useEffect(() => {
|
||||
if (isActive) setPaused(false);
|
||||
}, [hlsUrl, isActive]);
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
isDragging.current = false;
|
||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||
pan.setValue({ x: 0, y: 0 });
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
pan.x.setValue(gs.dx);
|
||||
pan.y.setValue(gs.dy);
|
||||
},
|
||||
onPanResponderRelease: (evt) => {
|
||||
pan.flattenOffset();
|
||||
if (!isDragging.current) {
|
||||
const { locationX, locationY } = evt.nativeEvent;
|
||||
const { roomId: rid, clearLive: clear, router: r } = storeRef.current;
|
||||
if (locationX > MINI_W - 28 && locationY < 28) {
|
||||
clear();
|
||||
} else {
|
||||
r.push(`/live/${rid}` as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { width: sw, height: sh } = Dimensions.get('window');
|
||||
snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh);
|
||||
},
|
||||
onPanResponderTerminate: () => { pan.flattenOffset(); },
|
||||
}),
|
||||
).current;
|
||||
const { pan, panHandlers } = useMiniDrag({
|
||||
width: MINI_W,
|
||||
height: MINI_H,
|
||||
hitClose: (x, y) => x > MINI_W - 28 && y < 28,
|
||||
onTap: () => router.push(`/live/${roomId}` as any),
|
||||
onClose: () => {
|
||||
setPaused(true);
|
||||
requestAnimationFrame(() => clearLive());
|
||||
},
|
||||
});
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
@@ -99,7 +57,7 @@ export function LiveMiniPlayer() {
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
|
||||
{...panResponder.panHandlers}
|
||||
{...panHandlers}
|
||||
>
|
||||
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.videoArea} />
|
||||
<View style={styles.liveBadge} pointerEvents="none">
|
||||
@@ -120,7 +78,7 @@ export function LiveMiniPlayer() {
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
|
||||
{...panResponder.panHandlers}
|
||||
{...panHandlers}
|
||||
>
|
||||
{/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */}
|
||||
<View style={styles.videoArea} pointerEvents="none">
|
||||
@@ -131,7 +89,7 @@ export function LiveMiniPlayer() {
|
||||
resizeMode="cover"
|
||||
controls={false}
|
||||
muted={false}
|
||||
paused={false}
|
||||
paused={paused}
|
||||
repeat={false}
|
||||
onError={clearLive}
|
||||
/>
|
||||
@@ -141,7 +99,7 @@ export function LiveMiniPlayer() {
|
||||
<Text style={styles.liveText}>LIVE</Text>
|
||||
</View>
|
||||
<Text style={styles.titleText} numberOfLines={1}>{title}</Text>
|
||||
{/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */}
|
||||
{/* 关闭按钮视觉层,点击逻辑由 hitClose 坐标判断 */}
|
||||
<View style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={14} color="#fff" />
|
||||
</View>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View, Text, Image, StyleSheet,
|
||||
Animated, PanResponder, Dimensions,
|
||||
} from 'react-native';
|
||||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet, Animated } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useVideoStore } from '../store/videoStore';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
import { useMiniDrag } from '../hooks/useMiniDrag';
|
||||
|
||||
const MINI_W = 160;
|
||||
const MINI_H = 90;
|
||||
@@ -16,57 +14,14 @@ export function MiniPlayer() {
|
||||
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pan = useRef(new Animated.ValueXY()).current;
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||
const storeRef = useRef({ bvid, clearVideo, router });
|
||||
storeRef.current = { bvid, clearVideo, router };
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
isDragging.current = false;
|
||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||
pan.setValue({ x: 0, y: 0 });
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
pan.x.setValue(gs.dx);
|
||||
pan.y.setValue(gs.dy);
|
||||
},
|
||||
onPanResponderRelease: (evt) => {
|
||||
pan.flattenOffset();
|
||||
if (!isDragging.current) {
|
||||
const { locationX, locationY } = evt.nativeEvent;
|
||||
const { bvid: vid, clearVideo: clear, router: r } = storeRef.current;
|
||||
if (locationX > MINI_W - 28 && locationY < 28) {
|
||||
clear();
|
||||
} else {
|
||||
r.push(`/video/${vid}` as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { width: sw, height: sh } = Dimensions.get('window');
|
||||
const curX = (pan.x as any)._value;
|
||||
const curY = (pan.y as any)._value;
|
||||
const snapRight = 0;
|
||||
const snapLeft = -(sw - MINI_W - 24);
|
||||
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
|
||||
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
|
||||
Animated.spring(pan, {
|
||||
toValue: { x: snapX, y: clampedY },
|
||||
useNativeDriver: false,
|
||||
tension: 120,
|
||||
friction: 10,
|
||||
}).start();
|
||||
},
|
||||
onPanResponderTerminate: () => { pan.flattenOffset(); },
|
||||
})
|
||||
).current;
|
||||
const { pan, panHandlers } = useMiniDrag({
|
||||
width: MINI_W,
|
||||
height: MINI_H,
|
||||
hitClose: (x, y) => x > MINI_W - 28 && y < 28,
|
||||
onTap: () => router.push(`/video/${bvid}` as any),
|
||||
onClose: clearVideo,
|
||||
});
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
@@ -75,11 +30,11 @@ export function MiniPlayer() {
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
|
||||
{...panResponder.panHandlers}
|
||||
{...panHandlers}
|
||||
>
|
||||
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.cover} />
|
||||
<Text style={styles.title} numberOfLines={1}>{title}</Text>
|
||||
{/* 关闭按钮仅作视觉展示,点击逻辑由 onPanResponderRelease 坐标判断处理 */}
|
||||
{/* 关闭按钮仅作视觉展示,点击逻辑由 hitClose 坐标判断处理 */}
|
||||
<View style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={14} color="#fff" />
|
||||
</View>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Modal,
|
||||
Image,
|
||||
PanResponder,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
@@ -25,11 +27,13 @@ import type {
|
||||
PlayUrlResponse,
|
||||
VideoShotData,
|
||||
DanmakuItem,
|
||||
IVideoPlayer,
|
||||
} from "../services/types";
|
||||
import { buildDashMpdUri } from "../utils/dash";
|
||||
import { getVideoShot } from "../services/bilibili";
|
||||
import DanmakuOverlay from "./DanmakuOverlay";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { usePlayProgressStore } from "../store/playProgressStore";
|
||||
|
||||
const BAR_H = 3;
|
||||
// 进度球尺寸
|
||||
@@ -59,8 +63,8 @@ function findFrameByTime(index: number[], seekTime: number): number {
|
||||
return lo;
|
||||
}
|
||||
|
||||
export interface NativeVideoPlayerRef {
|
||||
seek: (t: number) => void;
|
||||
export interface NativeVideoPlayerRef extends IVideoPlayer {
|
||||
/** @deprecated 用 pause()/resume() 代替 */
|
||||
setPaused: (v: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -110,6 +114,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [paused, setPaused] = useState(false);
|
||||
// seek 后强制触发 react-native-video 重新评估 paused prop 的 hack 用的瞬时叠加态
|
||||
// 单独存储以避免污染 paused(用于图标显示):seek 完成的一瞬间不让"播放/暂停"图标闪
|
||||
const [seekHackPaused, setSeekHackPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const currentTimeRef = useRef(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
@@ -118,15 +125,61 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
// 倍速
|
||||
const RATE_OPTIONS = [0.75, 1, 1.25, 1.5, 2];
|
||||
const [rate, setRate] = useState(1);
|
||||
const [showRate, setShowRate] = useState(false);
|
||||
|
||||
// 清晰度切换:保留进度 + loading 遮罩
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
const prevQnRef = useRef(currentQn);
|
||||
const switchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 续播:每 5s 持久化一次
|
||||
const lastSaveRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// 排除初始挂载(prevQn 或 currentQn === 0)
|
||||
if (
|
||||
prevQnRef.current !== 0 &&
|
||||
currentQn !== 0 &&
|
||||
prevQnRef.current !== currentQn
|
||||
) {
|
||||
pendingSeekRef.current = currentTimeRef.current;
|
||||
setSwitching(true);
|
||||
// 兜底:8s 内 onLoad 没触发就强制收起遮罩
|
||||
if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current);
|
||||
switchTimeoutRef.current = setTimeout(() => setSwitching(false), 8000);
|
||||
}
|
||||
prevQnRef.current = currentQn;
|
||||
}, [currentQn]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const isSeekingRef = useRef(false);
|
||||
const [touchX, setTouchX] = useState<number | null>(null);
|
||||
const touchXRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
// 拖动球位置用 Animated.Value 驱动:setValue 不触发 React 重渲染,
|
||||
// 原生层直接更新坐标,跟手 60fps。消除老方案 setState+60ms 节流导致的"段落感"。
|
||||
const touchAnimX = useRef(new Animated.Value(0)).current;
|
||||
// 缩略图换帧仍走 state(精灵图位移涉及 RN 视图属性变化),50ms 节流足够
|
||||
const [thumbFrame, setThumbFrame] = useState<{
|
||||
sheetIdx: number;
|
||||
col: number;
|
||||
row: number;
|
||||
seekTime: number;
|
||||
} | null>(null);
|
||||
const thumbThrottleRef = useRef(0);
|
||||
const barOffsetX = useRef(0);
|
||||
const barWidthRef = useRef(300);
|
||||
const trackRef = useRef<View>(null);
|
||||
// 让稳定的 PanResponder 闭包能读到最新 shots
|
||||
const shotsRef = useRef<VideoShotData | null>(null);
|
||||
|
||||
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||
const [showDanmaku, setShowDanmaku] = useState(true);
|
||||
@@ -137,6 +190,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
seek: (t: number) => {
|
||||
videoRef.current?.seek(t);
|
||||
},
|
||||
pause: () => setPaused(true),
|
||||
resume: () => setPaused(false),
|
||||
getCurrentTime: () => currentTimeRef.current,
|
||||
setPaused: (v: boolean) => {
|
||||
setPaused(v);
|
||||
},
|
||||
@@ -153,7 +209,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
return;
|
||||
}
|
||||
if (isDash) {
|
||||
buildDashMpdUri(playData, currentQn)
|
||||
buildDashMpdUri(playData, currentQn, bvid)
|
||||
.then(setResolvedUrl)
|
||||
.catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl));
|
||||
} else {
|
||||
@@ -179,6 +235,25 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
useEffect(() => {
|
||||
shotsRef.current = shots;
|
||||
}, [shots]);
|
||||
|
||||
// 非拖动时,球/进度填充随 currentTime 同步(onProgress 驱动)
|
||||
useEffect(() => {
|
||||
if (isSeekingRef.current) return;
|
||||
if (durationRef.current <= 0 || barWidthRef.current <= 0) {
|
||||
touchAnimX.setValue(0);
|
||||
return;
|
||||
}
|
||||
const x = clamp(
|
||||
(currentTime / durationRef.current) * barWidthRef.current,
|
||||
0,
|
||||
barWidthRef.current,
|
||||
);
|
||||
touchAnimX.setValue(x);
|
||||
}, [currentTime, duration]);
|
||||
|
||||
// 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
@@ -223,7 +298,29 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
|
||||
// 拖动中按 50ms 节流计算精灵图帧索引;球位置不走这里,由 Animated.setValue 直接更新
|
||||
const updateThumbFrame = useCallback((x: number) => {
|
||||
const shotsData = shotsRef.current;
|
||||
if (!shotsData || durationRef.current <= 0 || barWidthRef.current <= 0) return;
|
||||
const ratio = clamp(x / barWidthRef.current, 0, 1);
|
||||
const seekTime = ratio * durationRef.current;
|
||||
const { img_x_len, img_y_len, image, index } = shotsData;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const totalFrames = framesPerSheet * image.length;
|
||||
const frameIdx = index?.length
|
||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||
: clamp(Math.floor(ratio * (totalFrames - 1)), 0, totalFrames - 1);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
setThumbFrame({
|
||||
sheetIdx: Math.floor(frameIdx / framesPerSheet),
|
||||
col: local % img_x_len,
|
||||
row: Math.floor(local / img_x_len),
|
||||
seekTime,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// PanResponder 进度条拖动:球/进度填充走 Animated.setValue(无 React 渲染),
|
||||
// 缩略图换帧 50ms 节流;松手时 seek 到目标时间并恢复自动同步。
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
@@ -234,40 +331,39 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
setShowControls(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current);
|
||||
touchXRef.current = x;
|
||||
setTouchX(x);
|
||||
touchAnimX.setValue(x);
|
||||
thumbThrottleRef.current = 0;
|
||||
updateThumbFrame(x);
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
touchXRef.current = clamp(
|
||||
const x = clamp(
|
||||
gs.moveX - barOffsetX.current,
|
||||
0,
|
||||
barWidthRef.current,
|
||||
);
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setTouchX(touchXRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
// 关键:setValue 不触发 React 渲染,进度球/进度填充由原生层直接刷新
|
||||
touchAnimX.setValue(x);
|
||||
const now = Date.now();
|
||||
if (now - thumbThrottleRef.current >= 50) {
|
||||
thumbThrottleRef.current = now;
|
||||
updateThumbFrame(x);
|
||||
}
|
||||
},
|
||||
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
|
||||
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const ratio = clamp(
|
||||
(gs.moveX - barOffsetX.current) / barWidthRef.current,
|
||||
const x = clamp(
|
||||
gs.moveX - barOffsetX.current,
|
||||
0,
|
||||
1,
|
||||
barWidthRef.current,
|
||||
);
|
||||
const ratio = barWidthRef.current > 0 ? x / barWidthRef.current : 0;
|
||||
const t = ratio * durationRef.current;
|
||||
touchAnimX.setValue(x);
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
setThumbFrame(null);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setShowControls(false),
|
||||
@@ -275,71 +371,46 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
touchXRef.current = null;
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
setThumbFrame(null);
|
||||
},
|
||||
}),
|
||||
).current;
|
||||
// 进度条上触摸位置对应的时间点比例,0-1。非拖动状态为 null
|
||||
const touchRatio =
|
||||
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
||||
const progressRatio =
|
||||
duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||
|
||||
const THUMB_DISPLAY_W = 120; // scaled display width
|
||||
// 缩略图尺寸:竖屏播放器较窄给 160,全屏给 220 提升可读性
|
||||
const THUMB_DISPLAY_W = isFullscreen ? 220 : 160;
|
||||
|
||||
// 进度球水平偏移:active 态球更大,translate 偏移需对齐圆心
|
||||
const ballTranslate = React.useMemo(
|
||||
() => Animated.subtract(touchAnimX, (isSeeking ? BALL_ACTIVE : BALL) / 2),
|
||||
[isSeeking, touchAnimX],
|
||||
);
|
||||
|
||||
const renderThumbnail = () => {
|
||||
if (touchRatio === null || !shots || !isSeeking) return null;
|
||||
if (!thumbFrame || !shots || !isSeeking) return null;
|
||||
const {
|
||||
img_x_size: TW,
|
||||
img_y_size: TH,
|
||||
img_x_len,
|
||||
img_y_len,
|
||||
image,
|
||||
index,
|
||||
} = shots;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const totalFrames = framesPerSheet * image.length;
|
||||
const seekTime = touchRatio * duration;
|
||||
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
|
||||
const frameIdx =
|
||||
index?.length && duration > 0
|
||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||
: clamp(
|
||||
Math.floor(touchRatio * (totalFrames - 1)),
|
||||
0,
|
||||
totalFrames - 1,
|
||||
);
|
||||
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
const col = local % img_x_len;
|
||||
const row = Math.floor(local / img_x_len);
|
||||
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
||||
const { sheetIdx, col, row, seekTime } = thumbFrame;
|
||||
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
||||
const scale = THUMB_DISPLAY_W / TW;
|
||||
const DW = THUMB_DISPLAY_W;
|
||||
const DH = Math.round(TH * scale);
|
||||
|
||||
const trackLeft = barOffsetX.current;
|
||||
const absLeft = clamp(
|
||||
trackLeft + (touchX ?? 0) - DW / 2,
|
||||
0,
|
||||
SCREEN_W - DW,
|
||||
);
|
||||
// 缩略图固定到播放器水平中点,不跟随手指 / 进度球移动
|
||||
const fixedLeft = Math.round(SCREEN_W / 2 - DW / 2);
|
||||
const raw = image[sheetIdx];
|
||||
if (!raw) return null;
|
||||
// 兼容处理图床地址,确保以 http(s) 协议开头
|
||||
const sheetUrl = image[sheetIdx].startsWith("//")
|
||||
? `https:${image[sheetIdx]}`
|
||||
: image[sheetIdx];
|
||||
const sheetUrl = raw.startsWith("//") ? `https:${raw}` : raw;
|
||||
return (
|
||||
<View
|
||||
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
|
||||
style={[styles.thumbPreview, { left: fixedLeft, width: DW }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View
|
||||
@@ -347,7 +418,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
width: DW,
|
||||
height: DH,
|
||||
overflow: "hidden",
|
||||
borderRadius: 4,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -389,7 +460,8 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={!!(forcePaused || paused)}
|
||||
paused={!!(forcePaused || paused || seekHackPaused)}
|
||||
rate={rate}
|
||||
progressUpdateInterval={500}
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
@@ -398,6 +470,14 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
}) => {
|
||||
currentTimeRef.current = ct;
|
||||
onTimeUpdate?.(ct);
|
||||
// 续播持久化(5s 节流)
|
||||
if (bvid && dur > 0) {
|
||||
const nowSave = Date.now();
|
||||
if (nowSave - lastSaveRef.current > 5000) {
|
||||
lastSaveRef.current = nowSave;
|
||||
usePlayProgressStore.getState().save(bvid, ct, dur);
|
||||
}
|
||||
}
|
||||
// 拖动进度条时跳过 UI 更新,避免与用户拖动冲突
|
||||
if (isSeekingRef.current) return;
|
||||
const now = Date.now();
|
||||
@@ -408,20 +488,44 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
setBuffered(buf);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (initialTime && initialTime > 0) {
|
||||
// 切清晰度后跳回原进度
|
||||
const pending = pendingSeekRef.current;
|
||||
let didSeek = false;
|
||||
if (pending !== null && pending > 0) {
|
||||
videoRef.current?.seek(pending);
|
||||
pendingSeekRef.current = null;
|
||||
didSeek = true;
|
||||
} else if (initialTime && initialTime > 0) {
|
||||
videoRef.current?.seek(initialTime);
|
||||
didSeek = true;
|
||||
}
|
||||
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
|
||||
if (!forcePaused) {
|
||||
setPaused(true);
|
||||
requestAnimationFrame(() => setPaused(false));
|
||||
if (switching) {
|
||||
setSwitching(false);
|
||||
if (switchTimeoutRef.current) {
|
||||
clearTimeout(switchTimeoutRef.current);
|
||||
switchTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
// seek 后部分播放器不自动恢复播放,需短暂 paused→false 触发 prop 变化
|
||||
// 仅在确实 seek 时执行;走 seekHackPaused(不污染图标显示态),避免播放/暂停图标闪烁
|
||||
if (didSeek && !forcePaused && !paused) {
|
||||
setSeekHackPaused(true);
|
||||
requestAnimationFrame(() => setSeekHackPaused(false));
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 杜比视界播放失败时自动降级到 1080P
|
||||
if (currentQn === 126) {
|
||||
onQualityChange(80);
|
||||
return;
|
||||
// 按降级链找下一档可用清晰度(从当前档位的下一档起,跳过不在 qualities 列表里的)
|
||||
const FALLBACK_CHAIN = [126, 112, 80, 64, 32, 16];
|
||||
const idx = FALLBACK_CHAIN.indexOf(currentQn);
|
||||
if (idx >= 0) {
|
||||
const acceptable = new Set(qualities.map(q => q.qn));
|
||||
for (let i = idx + 1; i < FALLBACK_CHAIN.length; i++) {
|
||||
const next = FALLBACK_CHAIN[i];
|
||||
if (acceptable.has(next)) {
|
||||
onQualityChange(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn("Video playback error:", e);
|
||||
}}
|
||||
@@ -430,6 +534,13 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<View style={styles.switchOverlay} pointerEvents="none">
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
<Text style={styles.switchText}>切换到 {currentDesc}…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isFullscreen && !!danmakus?.length && (
|
||||
<DanmakuOverlay
|
||||
danmakus={danmakus}
|
||||
@@ -490,32 +601,26 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
width: touchAnimX,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
styles.ballActive,
|
||||
{ left: touchX - BALL_ACTIVE / 2 },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
{ left: progressRatio * barWidthRef.current - BALL / 2 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.ball,
|
||||
isSeeking && styles.ballActive,
|
||||
{
|
||||
left: 0,
|
||||
transform: [{ translateX: ballTranslate }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{/* Controls */}
|
||||
|
||||
@@ -538,6 +643,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowRate(true)}
|
||||
>
|
||||
<Text style={styles.qualityText}>{rate === 1 ? "倍速" : `${rate}x`}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowQuality(true)}
|
||||
@@ -601,6 +712,42 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* 选倍速 */}
|
||||
<Modal visible={showRate} transparent animationType="fade">
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setShowRate(false)}
|
||||
>
|
||||
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
|
||||
<Text style={[styles.qualityTitle, { color: theme.modalText }]}>选择倍速</Text>
|
||||
{RATE_OPTIONS.map((r) => (
|
||||
<TouchableOpacity
|
||||
key={r}
|
||||
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
|
||||
onPress={() => {
|
||||
setRate(r);
|
||||
setShowRate(false);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
{ color: theme.modalTextSub },
|
||||
r === rate && styles.qualityItemActive,
|
||||
]}
|
||||
>
|
||||
{r === 1 ? "正常" : `${r}x`}
|
||||
</Text>
|
||||
{r === rate && (
|
||||
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
@@ -610,6 +757,19 @@ const styles = StyleSheet.create({
|
||||
container: { backgroundColor: "#000" },
|
||||
fsContainer: { flex: 1, backgroundColor: "#000" },
|
||||
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
|
||||
switchOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
},
|
||||
switchText: {
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
},
|
||||
topBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
127
components/VideoDetailSkeleton.tsx
Normal file
127
components/VideoDetailSkeleton.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useRef, memo } from "react";
|
||||
import { View, StyleSheet, Animated } from "react-native";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
// 注意:原版本每个 SkeletonBlock 都调 useTheme,导致 40+ 个 store 订阅
|
||||
// + 整体 6 张卡片 + opacity loop,进入页面瞬间产生明显抖动。
|
||||
// 现优化:颜色由顶层 useTheme 一次拿到,所有 block 走 inline style。
|
||||
|
||||
interface BlockProps {
|
||||
width: number | `${number}%`;
|
||||
height: number;
|
||||
radius?: number;
|
||||
bg: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
const SkeletonBlock = memo(function SkeletonBlock({ width, height, radius = 4, bg, style }: BlockProps) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ width, height, borderRadius: radius, backgroundColor: bg },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const RelatedCardSkeleton = memo(function RelatedCardSkeleton({ bg, border }: { bg: string; border: string }) {
|
||||
return (
|
||||
<View style={[styles.card, { borderBottomColor: border }]}>
|
||||
<SkeletonBlock width={120} height={68} radius={4} bg={bg} />
|
||||
<View style={styles.cardInfo}>
|
||||
<View>
|
||||
<SkeletonBlock width={"100%"} height={12} bg={bg} />
|
||||
<SkeletonBlock width={"70%"} height={12} bg={bg} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
<View style={styles.cardMeta}>
|
||||
<SkeletonBlock width={70} height={10} bg={bg} />
|
||||
<SkeletonBlock width={50} height={10} bg={bg} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const CARD_COUNT = 4;
|
||||
|
||||
export function VideoDetailSkeleton() {
|
||||
const theme = useTheme();
|
||||
const opacity = useRef(new Animated.Value(0.6)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0.6, duration: 800, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [opacity]);
|
||||
|
||||
const bg = theme.placeholder;
|
||||
const border = theme.border;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { backgroundColor: theme.card, opacity }]}>
|
||||
{/* UP 主行 */}
|
||||
<View style={styles.upRow}>
|
||||
<SkeletonBlock width={38} height={38} radius={19} bg={bg} />
|
||||
<View style={styles.upInfo}>
|
||||
<SkeletonBlock width={120} height={13} bg={bg} />
|
||||
<SkeletonBlock width={160} height={11} bg={bg} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 标题 + 简介 */}
|
||||
<View style={[styles.titleSection, { borderBottomColor: border }]}>
|
||||
<SkeletonBlock width={"95%"} height={14} bg={bg} />
|
||||
<SkeletonBlock width={"60%"} height={14} bg={bg} style={{ marginTop: 8 }} />
|
||||
<SkeletonBlock width={48} height={16} radius={4} bg={bg} style={{ marginTop: 12 }} />
|
||||
<SkeletonBlock width={"100%"} height={12} bg={bg} style={{ marginTop: 12 }} />
|
||||
<SkeletonBlock width={"80%"} height={12} bg={bg} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
|
||||
{/* 推荐视频标题 */}
|
||||
<View style={styles.relatedHeader}>
|
||||
<SkeletonBlock width={64} height={13} bg={bg} />
|
||||
</View>
|
||||
|
||||
{/* 推荐卡片占位(4 张足够撑满首屏,少绘制开销) */}
|
||||
{Array.from({ length: CARD_COUNT }).map((_, i) => (
|
||||
<RelatedCardSkeleton key={i} bg={bg} border={border} />
|
||||
))}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
upRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
upInfo: { flex: 1, marginLeft: 10 },
|
||||
titleSection: {
|
||||
padding: 14,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
relatedHeader: {
|
||||
paddingLeft: 13,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
card: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: 10,
|
||||
},
|
||||
cardInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
|
||||
cardMeta: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
});
|
||||
@@ -15,14 +15,21 @@ interface Props {
|
||||
cid?: number;
|
||||
danmakus?: DanmakuItem[];
|
||||
onTimeUpdate?: (t: number) => void;
|
||||
initialTime?: number;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate }: Props) {
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate, initialTime }: Props) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
const needsRotation = !ScreenOrientation && fullscreen;
|
||||
const lastTimeRef = useRef(0);
|
||||
const seededRef = useRef(false);
|
||||
// 续播:第一次拿到 initialTime 时塞进 lastTimeRef
|
||||
if (!seededRef.current && typeof initialTime === 'number' && initialTime > 0) {
|
||||
lastTimeRef.current = initialTime;
|
||||
seededRef.current = true;
|
||||
}
|
||||
const portraitRef = useRef<NativeVideoPlayerRef>(null);
|
||||
|
||||
const handleEnterFullscreen = async () => {
|
||||
|
||||
46
hooks/useAutoHideTimer.ts
Normal file
46
hooks/useAutoHideTimer.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 控制栏自动隐藏 hook:show() 后 delayMs 内无交互即隐藏。
|
||||
* keep() 用于"按住进度条不让消失"等场景:返回 true 时计时器会被推迟。
|
||||
*/
|
||||
export function useAutoHideTimer(delayMs = 3000, keep?: () => boolean) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (keep?.()) return;
|
||||
timerRef.current = setTimeout(() => setVisible(false), delayMs);
|
||||
}, [delayMs, keep]);
|
||||
|
||||
const show = useCallback(() => {
|
||||
setVisible(true);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setVisible(prev => {
|
||||
if (!prev) {
|
||||
reset();
|
||||
return true;
|
||||
}
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
return false;
|
||||
});
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { visible, show, hide, toggle, reset };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { getLiveDanmakuHistory } from '../services/bilibili';
|
||||
import type { DanmakuItem } from '../services/types';
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
const POLL_INTERVAL = 1500;
|
||||
|
||||
// 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1"
|
||||
const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/;
|
||||
@@ -36,9 +36,10 @@ export function useLiveDanmaku(roomId: number): {
|
||||
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
|
||||
if (cancelled) return;
|
||||
|
||||
// 去重弹幕
|
||||
// 去重弹幕:以 uname + text + timeline 为 key
|
||||
// 用 timeline 区分"同一人重复发同句"的合法重复(gethistory 多次轮询会返回相同尾部数据,靠 timeline 过滤掉)
|
||||
const newItems = items.filter(item => {
|
||||
const key = `${item.uname ?? ''}:${item.text}`;
|
||||
const key = `${item.uname ?? ''}:${item.text}:${item.timeline ?? ''}`;
|
||||
if (seenTextsRef.current.has(key)) return false;
|
||||
seenTextsRef.current.add(key);
|
||||
return true;
|
||||
@@ -50,7 +51,6 @@ export function useLiveDanmaku(roomId: number): {
|
||||
// 解析 admin 消息中的礼物
|
||||
const newGifts: Record<string, number> = {};
|
||||
for (const msg of adminMsgs) {
|
||||
console.log(msg,123)
|
||||
// 去重 admin 消息
|
||||
if (seenAdminRef.current.has(msg)) continue;
|
||||
seenAdminRef.current.add(msg);
|
||||
|
||||
70
hooks/useMiniDrag.ts
Normal file
70
hooks/useMiniDrag.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useRef } from 'react';
|
||||
import { Animated, Dimensions, PanResponder } from 'react-native';
|
||||
|
||||
interface Options {
|
||||
width: number;
|
||||
height: number;
|
||||
/** 命中"关闭按钮"区域的判定(locationX, locationY)→ true 时调 onClose,否则调 onTap */
|
||||
hitClose?: (locationX: number, locationY: number) => boolean;
|
||||
onTap: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局浮动小窗的拖拽 + 吸边 + 点击/关闭分发。
|
||||
* 返回 panHandlers 直接展开到容器,transform 应用 pan.getTranslateTransform()。
|
||||
*/
|
||||
export function useMiniDrag({ width, height, hitClose, onTap, onClose }: Options) {
|
||||
const pan = useRef(new Animated.ValueXY()).current;
|
||||
const isDragging = useRef(false);
|
||||
// 用 ref 保持最新回调,避免 PanResponder 闭包过期
|
||||
const cbRef = useRef({ onTap, onClose, hitClose });
|
||||
cbRef.current = { onTap, onClose, hitClose };
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
isDragging.current = false;
|
||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||
pan.setValue({ x: 0, y: 0 });
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
pan.x.setValue(gs.dx);
|
||||
pan.y.setValue(gs.dy);
|
||||
},
|
||||
onPanResponderRelease: (evt) => {
|
||||
pan.flattenOffset();
|
||||
if (!isDragging.current) {
|
||||
const { locationX, locationY } = evt.nativeEvent;
|
||||
const cb = cbRef.current;
|
||||
if (cb.hitClose?.(locationX, locationY) && cb.onClose) {
|
||||
cb.onClose();
|
||||
} else {
|
||||
cb.onTap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { width: sw, height: sh } = Dimensions.get('window');
|
||||
const curX = (pan.x as any)._value;
|
||||
const curY = (pan.y as any)._value;
|
||||
const snapRight = 0;
|
||||
const snapLeft = -(sw - width - 24);
|
||||
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
|
||||
const clampedY = Math.max(-sh + height + 60, Math.min(60, curY));
|
||||
Animated.spring(pan, {
|
||||
toValue: { x: snapX, y: clampedY },
|
||||
useNativeDriver: false,
|
||||
tension: 120,
|
||||
friction: 10,
|
||||
}).start();
|
||||
},
|
||||
onPanResponderTerminate: () => { pan.flattenOffset(); },
|
||||
}),
|
||||
).current;
|
||||
|
||||
return { pan, panHandlers: panResponder.panHandlers };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { getVideoDetail, getPlayUrl } from '../services/bilibili';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { usePlayProgressStore } from '../store/playProgressStore';
|
||||
import type { VideoItem, PlayUrlResponse } from '../services/types';
|
||||
|
||||
export function useVideoDetail(bvid: string) {
|
||||
@@ -11,6 +12,7 @@ export function useVideoDetail(bvid: string) {
|
||||
const [currentQn, setCurrentQn] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [initialTime, setInitialTime] = useState(0);
|
||||
const cidRef = useRef<number>(0);
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
@@ -38,6 +40,8 @@ export function useVideoDetail(bvid: string) {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 读取续播位置
|
||||
setInitialTime(usePlayProgressStore.getState().get(bvid));
|
||||
const detail = await getVideoDetail(bvid);
|
||||
setVideo(detail);
|
||||
const cid = detail.pages?.[0]?.cid ?? detail.cid as number;
|
||||
@@ -53,13 +57,32 @@ export function useVideoDetail(bvid: string) {
|
||||
}, [bvid]);
|
||||
|
||||
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
|
||||
// cancelled flag 防止旧响应(切换登录态后)覆盖新响应
|
||||
useEffect(() => {
|
||||
if (cidRef.current) {
|
||||
fetchPlayData(cidRef.current, defaultQn, true).catch((e) => {
|
||||
console.warn('Failed to refresh quality list after login change:', e);
|
||||
});
|
||||
}
|
||||
if (!cidRef.current) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getPlayUrl(bvid, cidRef.current, defaultQn);
|
||||
if (cancelled) return;
|
||||
setPlayData(data);
|
||||
setCurrentQn(data.quality);
|
||||
if (data.accept_quality?.length) {
|
||||
setQualities(
|
||||
data.accept_quality.map((q, i) => ({
|
||||
qn: q,
|
||||
desc: data.accept_description?.[i] ?? String(q),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) console.warn('Failed to refresh quality list after login change:', e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn]);
|
||||
|
||||
return { video, playData, loading, error, qualities, currentQn, changeQuality };
|
||||
return { video, playData, loading, error, qualities, currentQn, changeQuality, initialTime };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { getRecommendFeed, getLiveList } from '../services/bilibili';
|
||||
import type { VideoItem, LiveRoom } from '../services/types';
|
||||
import { getRecommendFeed } from '../services/bilibili';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export function useVideoList() {
|
||||
const [pages, setPages] = useState<VideoItem[][]>([]);
|
||||
const [liveRooms, setLiveRooms] = useState<LiveRoom[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Use refs to avoid stale closures — load() has stable identity
|
||||
const loadingRef = useRef(false);
|
||||
const freshIdxRef = useRef(0);
|
||||
|
||||
@@ -21,19 +19,8 @@ export function useVideoList() {
|
||||
const idx = freshIdxRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [
|
||||
getRecommendFeed(idx),
|
||||
(reset || idx === 0)
|
||||
? getLiveList(1, 0).catch(() => [] as LiveRoom[])
|
||||
: Promise.resolve([] as LiveRoom[]),
|
||||
];
|
||||
const [data, live] = await Promise.all(promises);
|
||||
const data = await getRecommendFeed(idx);
|
||||
setPages(prev => reset ? [data] : [...prev, data]);
|
||||
if (reset || idx === 0) {
|
||||
// Take top 2 by online count
|
||||
const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 10);
|
||||
setLiveRooms(sorted);
|
||||
}
|
||||
freshIdxRef.current = idx + 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to load videos', e);
|
||||
@@ -42,7 +29,7 @@ export function useVideoList() {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []); // stable — no stale closure risk
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
console.log('Refreshing video list');
|
||||
@@ -51,5 +38,5 @@ export function useVideoList() {
|
||||
}, [load]);
|
||||
|
||||
const videos = useMemo(() => pages.flat(), [pages]);
|
||||
return { videos, pages, liveRooms, loading, refreshing, load, refresh };
|
||||
return { videos, pages, loading, refreshing, load, refresh };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,32 @@ function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避重试包装。仅对网络层面错误(无 response、5xx、超时)重试;
|
||||
* 业务错误(如 401/403/-352)直接抛出,不重试。
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries = 2,
|
||||
backoff = [500, 1500],
|
||||
): Promise<T> {
|
||||
let lastErr: any;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
lastErr = e;
|
||||
const status = e?.response?.status;
|
||||
const isNetwork = !e?.response || e?.code === 'ECONNABORTED';
|
||||
const is5xx = typeof status === 'number' && status >= 500;
|
||||
if (!isNetwork && !is5xx) throw e;
|
||||
if (attempt === retries) throw e;
|
||||
await new Promise((r) => setTimeout(r, backoff[attempt] ?? backoff[backoff.length - 1]));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// WBI key cache (rotates ~daily)
|
||||
let wbiKeys: { imgKey: string; subKey: string } | null = null;
|
||||
let wbiKeysTimestamp = 0;
|
||||
@@ -128,10 +154,12 @@ export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
|
||||
}
|
||||
|
||||
export function getVideoDetail(bvid: string): Promise<VideoItem> {
|
||||
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
|
||||
const res = await api.get('/x/web-interface/view', { params: { bvid } });
|
||||
return res.data.data as VideoItem;
|
||||
});
|
||||
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), () =>
|
||||
withRetry(async () => {
|
||||
const res = await api.get('/x/web-interface/view', { params: { bvid } });
|
||||
return res.data.data as VideoItem;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
|
||||
@@ -147,10 +175,12 @@ export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlR
|
||||
const params = isAndroid
|
||||
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
|
||||
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
||||
return dedupe(dedupeKey('/x/player/playurl', params), async () => {
|
||||
const res = await api.get('/x/player/playurl', { params });
|
||||
return res.data.data as PlayUrlResponse;
|
||||
});
|
||||
return dedupe(dedupeKey('/x/player/playurl', params), () =>
|
||||
withRetry(async () => {
|
||||
const res = await api.get('/x/player/playurl', { params });
|
||||
return res.data.data as PlayUrlResponse;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPlayUrlForDownload(
|
||||
@@ -443,8 +473,9 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
|
||||
danmakus: DanmakuItem[];
|
||||
adminMsgs: string[];
|
||||
}> {
|
||||
// gethistory 默认返回 ~10 条;尝试带 count 参数拉更多,B 站不支持时会自动忽略
|
||||
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/dM/gethistory`, {
|
||||
params: { roomid: roomId },
|
||||
params: { roomid: roomId, room_id: roomId, count: 30 },
|
||||
});
|
||||
|
||||
const room: any[] = res.data?.data?.room ?? [];
|
||||
@@ -467,7 +498,7 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
|
||||
}
|
||||
|
||||
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||
try {
|
||||
return withRetry(async () => {
|
||||
if (isWeb) {
|
||||
// web 走代理,代理已解压,直接拿文本
|
||||
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
|
||||
@@ -501,10 +532,10 @@ export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||
}
|
||||
|
||||
return parseDanmakuXml(xmlText);
|
||||
} catch (e) {
|
||||
}).catch((e) => {
|
||||
console.warn('getDanmaku failed:', e);
|
||||
return [];
|
||||
}
|
||||
return [] as DanmakuItem[];
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* 三种播放器(视频详情、首页大卡、直播)暴露的统一 ref 接口。
|
||||
* 不一定每个实现都全部支持,未实现的方法可空操作。
|
||||
*/
|
||||
export interface IVideoPlayer {
|
||||
seek: (timeSec: number) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
getCurrentTime: () => number;
|
||||
}
|
||||
|
||||
export interface VideoItem {
|
||||
bvid: string;
|
||||
aid: number;
|
||||
|
||||
30
store/miniExclusion.ts
Normal file
30
store/miniExclusion.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* MiniPlayer ↔ LiveMiniPlayer 互斥协调。
|
||||
* 通过 zustand subscribe 在外部串联两个 store,避免它们直接互相 import
|
||||
* 造成循环依赖(曾导致 Metro / TS 编译时内存暴涨)。
|
||||
*
|
||||
* 仅需在 app 启动时 import 一次(_layout.tsx)即可生效。
|
||||
*/
|
||||
import { useVideoStore } from './videoStore';
|
||||
import { useLiveStore } from './liveStore';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initMiniExclusion() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// 视频 mini 激活 → 清掉直播 mini
|
||||
useVideoStore.subscribe((state, prev) => {
|
||||
if (state.isActive && !prev.isActive && useLiveStore.getState().isActive) {
|
||||
useLiveStore.getState().clearLive();
|
||||
}
|
||||
});
|
||||
|
||||
// 直播 mini 激活 → 清掉视频 mini
|
||||
useLiveStore.subscribe((state, prev) => {
|
||||
if (state.isActive && !prev.isActive && useVideoStore.getState().isActive) {
|
||||
useVideoStore.getState().clearVideo();
|
||||
}
|
||||
});
|
||||
}
|
||||
74
store/playProgressStore.ts
Normal file
74
store/playProgressStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const STORAGE_KEY = 'play_progress';
|
||||
// 持久化最多 200 条记录
|
||||
const MAX_ENTRIES = 200;
|
||||
// 视频接近结尾(>= 95%)视为看完,清掉记录
|
||||
const FINISH_RATIO = 0.95;
|
||||
|
||||
interface ProgressEntry {
|
||||
bvid: string;
|
||||
time: number; // 秒
|
||||
duration: number; // 秒
|
||||
ts: number; // 写入时间戳
|
||||
}
|
||||
|
||||
interface PlayProgressStore {
|
||||
records: Record<string, ProgressEntry>;
|
||||
hydrated: boolean;
|
||||
hydrate: () => Promise<void>;
|
||||
save: (bvid: string, time: number, duration: number) => void;
|
||||
get: (bvid: string) => number; // 返回应跳转的秒数(无记录返回 0)
|
||||
clear: (bvid: string) => void;
|
||||
}
|
||||
|
||||
const persist = (records: Record<string, ProgressEntry>) => {
|
||||
// 控制总量:超量删最早
|
||||
const entries = Object.values(records).sort((a, b) => b.ts - a.ts);
|
||||
const trimmed = entries.slice(0, MAX_ENTRIES);
|
||||
const trimmedMap: Record<string, ProgressEntry> = {};
|
||||
trimmed.forEach(e => { trimmedMap[e.bvid] = e; });
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedMap)).catch(() => {});
|
||||
return trimmedMap;
|
||||
};
|
||||
|
||||
export const usePlayProgressStore = create<PlayProgressStore>((set, get) => ({
|
||||
records: {},
|
||||
hydrated: false,
|
||||
hydrate: async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const records = JSON.parse(raw);
|
||||
set({ records, hydrated: true });
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
set({ hydrated: true });
|
||||
},
|
||||
save: (bvid, time, duration) => {
|
||||
if (!bvid || !duration || duration <= 0) return;
|
||||
// 视频接近结尾,清掉记录
|
||||
if (time / duration >= FINISH_RATIO) {
|
||||
get().clear(bvid);
|
||||
return;
|
||||
}
|
||||
// 起始 5 秒不必记录
|
||||
if (time < 5) return;
|
||||
const records = { ...get().records };
|
||||
records[bvid] = { bvid, time, duration, ts: Date.now() };
|
||||
set({ records: persist(records) });
|
||||
},
|
||||
get: (bvid) => {
|
||||
const r = get().records[bvid];
|
||||
return r?.time ?? 0;
|
||||
},
|
||||
clear: (bvid) => {
|
||||
const records = { ...get().records };
|
||||
if (records[bvid]) {
|
||||
delete records[bvid];
|
||||
set({ records: persist(records) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
43
store/playUrlCache.ts
Normal file
43
store/playUrlCache.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PlayUrlResponse } from '../services/types';
|
||||
|
||||
interface CacheEntry {
|
||||
playData: PlayUrlResponse;
|
||||
mpdUri?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface PlayUrlCacheStore {
|
||||
// 不通过 set 触发渲染(只是用 store 持有 Map)
|
||||
cache: Map<string, CacheEntry>;
|
||||
get: (bvid: string, qn: number) => CacheEntry | undefined;
|
||||
set: (bvid: string, qn: number, entry: Omit<CacheEntry, 'ts'>) => void;
|
||||
}
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_ENTRIES = 30;
|
||||
|
||||
const makeKey = (bvid: string, qn: number) => `${bvid}_${qn}`;
|
||||
|
||||
export const usePlayUrlCache = create<PlayUrlCacheStore>((_set, getState) => ({
|
||||
cache: new Map(),
|
||||
get: (bvid, qn) => {
|
||||
const map = getState().cache;
|
||||
const entry = map.get(makeKey(bvid, qn));
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() - entry.ts > TTL_MS) {
|
||||
map.delete(makeKey(bvid, qn));
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
set: (bvid, qn, entry) => {
|
||||
const map = getState().cache;
|
||||
map.set(makeKey(bvid, qn), { ...entry, ts: Date.now() });
|
||||
// 简单 LRU:超过容量删最早
|
||||
if (map.size > MAX_ENTRIES) {
|
||||
const oldestKey = map.keys().next().value;
|
||||
if (oldestKey) map.delete(oldestKey);
|
||||
}
|
||||
},
|
||||
}));
|
||||
11
store/visibleBigKeyStore.ts
Normal file
11
store/visibleBigKeyStore.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface VisibleBigKeyState {
|
||||
key: string | null;
|
||||
setKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
export const useVisibleBigKeyStore = create<VisibleBigKeyState>((set) => ({
|
||||
key: null,
|
||||
setKey: (key) => set({ key }),
|
||||
}));
|
||||
@@ -5,13 +5,35 @@ import type { PlayUrlResponse, DashAudioItem } from '../services/types';
|
||||
* 将 Bilibili DASH 响应写成 MPD 文件,返回 file:// URI 供 ExoPlayer 播放。
|
||||
* 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。
|
||||
*/
|
||||
export async function buildDashMpdUri(playData: PlayUrlResponse, qn: number): Promise<string> {
|
||||
export async function buildDashMpdUri(
|
||||
playData: PlayUrlResponse,
|
||||
qn: number,
|
||||
bvid?: string,
|
||||
): Promise<string> {
|
||||
const xml = buildMpdXml(playData, qn);
|
||||
const path = `${FileSystem.cacheDirectory}bili_dash_${qn}.mpd`;
|
||||
// 带 bvid 区分,避免不同视频在同一 qn 上复用同一文件名导致命中过期 MPD
|
||||
const suffix = bvid ? `${bvid}_${qn}` : String(qn);
|
||||
const path = `${FileSystem.cacheDirectory}bili_dash_${suffix}.mpd`;
|
||||
await FileSystem.writeAsStringAsync(path, xml, { encoding: FileSystem.EncodingType.UTF8 });
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 删除指定视频的所有清晰度 MPD 缓存(详情页卸载时调用) */
|
||||
export async function cleanupDashMpd(bvid: string): Promise<void> {
|
||||
if (!bvid || !FileSystem.cacheDirectory) return;
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(FileSystem.cacheDirectory);
|
||||
const targets = files.filter(f => f.startsWith(`bili_dash_${bvid}_`) && f.endsWith('.mpd'));
|
||||
await Promise.all(
|
||||
targets.map(f =>
|
||||
FileSystem.deleteAsync(`${FileSystem.cacheDirectory}${f}`, { idempotent: true }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// 忽略,缓存清理失败不影响功能
|
||||
}
|
||||
}
|
||||
|
||||
function isDolbyVision(codecs: string): boolean {
|
||||
return /^(dvhe|dvh1)/.test(codecs);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { VideoItem, LiveRoom } from '../services/types';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export interface NormalRow {
|
||||
type: 'pair';
|
||||
@@ -11,22 +11,14 @@ export interface BigRow {
|
||||
item: VideoItem;
|
||||
}
|
||||
|
||||
export interface LiveRow {
|
||||
type: 'live';
|
||||
left: LiveRoom;
|
||||
right?: LiveRoom;
|
||||
}
|
||||
export type ListRow = NormalRow | BigRow;
|
||||
|
||||
export type ListRow = NormalRow | BigRow | LiveRow;
|
||||
|
||||
export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRow[] {
|
||||
export function toListRows(pages: VideoItem[][]): ListRow[] {
|
||||
const rows: ListRow[] = [];
|
||||
let roomIdx = 0;
|
||||
|
||||
for (const chunk of pages) {
|
||||
if (chunk.length === 0) continue;
|
||||
|
||||
// Highest view count becomes BigRow
|
||||
let bigIdx = 0;
|
||||
let maxView = chunk[0].stat?.view ?? 0;
|
||||
for (let i = 1; i < chunk.length; i++) {
|
||||
@@ -37,36 +29,17 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo
|
||||
const bigItem = chunk[bigIdx];
|
||||
const rest = chunk.filter((_, i) => i !== bigIdx);
|
||||
|
||||
const pairs: (NormalRow | LiveRow)[] = [];
|
||||
const pairs: NormalRow[] = [];
|
||||
for (let i = 0; i < rest.length; i += 2) {
|
||||
if (rest[i + 1]) {
|
||||
pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (liveRooms && liveRooms.length >= 2) {
|
||||
const a = liveRooms[roomIdx % liveRooms.length];
|
||||
const b = liveRooms[(roomIdx + 1) % liveRooms.length];
|
||||
roomIdx += 2;
|
||||
|
||||
if (rows.length < 20) {
|
||||
rows.push({ type: 'big', item: bigItem });
|
||||
rows.push({ type: 'live', left: a, right: b });
|
||||
rows.push(...pairs);
|
||||
} else {
|
||||
rows.push(...pairs);
|
||||
rows.push({ type: 'big', item: bigItem });
|
||||
rows.push({ type: 'live', left: a, right: b });
|
||||
}
|
||||
if (rows.length < 20) {
|
||||
rows.push({ type: 'big', item: bigItem }, ...pairs);
|
||||
} else {
|
||||
// No live data, fall back to original logic
|
||||
if (rows.length < 20) {
|
||||
rows.push({ type: 'big', item: bigItem }, ...pairs);
|
||||
} else {
|
||||
rows.push(...pairs, { type: 'big', item: bigItem });
|
||||
}
|
||||
rows.push(...pairs, { type: 'big', item: bigItem });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
|
||||
Reference in New Issue
Block a user