mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
fix: 修复小窗闭包过期、详情页 loading 卡死、视频播放器性能问题
- 小窗 PanResponder 用 storeRef 替代闭包捕获,修复 roomId/bvid 始终为初始值 - useLiveDetail 用 ref 比对替代 cancelled 标志,防止 fetch 被意外取消 - 详情页 useLayoutEffect 同步清除小窗,BigVideoCard 小窗活跃时跳过播放 - 视频播放器竖屏/全屏互斥渲染,减半解码器占用 - onProgress 节流 + 退出全屏强制恢复播放
This commit is contained in:
@@ -23,6 +23,7 @@ import { getPlayUrl, getVideoDetail } from "../services/bilibili";
|
||||
import { coverImageUrl } from "../utils/imageUrl";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { useLiveStore } from "../store/liveStore";
|
||||
import { formatCount, formatDuration } from "../utils/format";
|
||||
import type { VideoItem } from "../services/types";
|
||||
|
||||
@@ -57,6 +58,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
}: Props) {
|
||||
const { width: SCREEN_W } = useWindowDimensions();
|
||||
const trafficSaving = useSettingsStore(s => s.trafficSaving);
|
||||
const liveActive = useLiveStore(s => s.isActive);
|
||||
const theme = useTheme();
|
||||
const THUMB_H = SCREEN_W * 0.5625;
|
||||
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
||||
@@ -92,7 +94,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
|
||||
// Preload: fetch play URL on mount (before card is visible)
|
||||
useEffect(() => {
|
||||
if (videoUrl || trafficSaving) return;
|
||||
if (videoUrl || trafficSaving || liveActive) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -127,8 +129,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
// Pause/resume based on visibility and scroll state
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
if (!isVisible || trafficSaving) {
|
||||
// Off-screen or traffic saving: pause, mute, show thumbnail
|
||||
if (!isVisible || trafficSaving || liveActive) {
|
||||
setPaused(true);
|
||||
setMuted(true);
|
||||
Animated.timing(thumbOpacity, {
|
||||
@@ -137,10 +138,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else if (isScrolling) {
|
||||
// Visible but scrolling: just pause (keep thumbnail hidden, keep mute state)
|
||||
setPaused(true);
|
||||
} else {
|
||||
// Visible and not scrolling: play, fade out thumbnail
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
@@ -148,10 +147,10 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [isVisible, isScrolling, videoUrl, trafficSaving]);
|
||||
}, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]);
|
||||
|
||||
const handleVideoReady = () => {
|
||||
if (!isVisible || isScrolling || trafficSaving) return;
|
||||
if (!isVisible || isScrolling || trafficSaving || liveActive) return;
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
@@ -229,7 +228,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
{/* Media area */}
|
||||
<View style={[mediaDimensions, { position: "relative" }]}>
|
||||
{/* Video player — rendered first so it sits behind the thumbnail */}
|
||||
{videoUrl && (
|
||||
{videoUrl && !liveActive && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function DanmakuList({
|
||||
{ opacity: item._fadeAnim, borderBottomColor: theme.border },
|
||||
]}
|
||||
>
|
||||
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
|
||||
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
|
||||
<View style={liveStyles.msgBody}>
|
||||
{guard && (
|
||||
<View
|
||||
@@ -242,7 +242,9 @@ export default function DanmakuList({
|
||||
<View style={liveStyles.medalTag}>
|
||||
<Text style={liveStyles.medalName}>{item.medalName}</Text>
|
||||
<View style={liveStyles.medalLvBox}>
|
||||
<Text style={liveStyles.medalLv}>{item.medalLevel}</Text>
|
||||
<Text style={[liveStyles.medalLv, { color: theme.text }]}>
|
||||
{item.medalLevel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -257,7 +259,6 @@ export default function DanmakuList({
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
@@ -451,7 +452,7 @@ const liveStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent:"space-between",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 5,
|
||||
},
|
||||
time: {
|
||||
|
||||
@@ -52,6 +52,10 @@ export function LiveMiniPlayer() {
|
||||
const pan = useRef(new Animated.ValueXY()).current;
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
|
||||
const storeRef = useRef({ roomId, clearLive, router });
|
||||
storeRef.current = { roomId, clearLive, router };
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
@@ -71,10 +75,11 @@ export function LiveMiniPlayer() {
|
||||
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) {
|
||||
clearLive();
|
||||
clear();
|
||||
} else {
|
||||
router.push(`/live/${roomId}` as any);
|
||||
r.push(`/live/${rid}` as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,12 @@ export function MiniPlayer() {
|
||||
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({
|
||||
// 从 start 阶段即抢占响应权,确保拖动可靠触发
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
isDragging.current = false;
|
||||
@@ -38,16 +41,15 @@ export function MiniPlayer() {
|
||||
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) {
|
||||
clearVideo();
|
||||
clear();
|
||||
} else {
|
||||
router.push(`/video/${bvid}` as any);
|
||||
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;
|
||||
|
||||
@@ -109,8 +109,10 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const currentTimeRef = useRef(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const durationRef = useRef(0);
|
||||
const lastProgressUpdate = useRef(0);
|
||||
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
@@ -318,16 +320,6 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
const local = frameIdx % framesPerSheet;
|
||||
const col = local % img_x_len;
|
||||
const row = Math.floor(local / img_x_len);
|
||||
console.log("[thumb]", {
|
||||
seekTime,
|
||||
duration,
|
||||
indexLen: index?.length,
|
||||
frameIdx,
|
||||
totalFrames,
|
||||
sheetIdx,
|
||||
col,
|
||||
row,
|
||||
});
|
||||
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
|
||||
const scale = THUMB_DISPLAY_W / TW;
|
||||
const DW = THUMB_DISPLAY_W;
|
||||
@@ -396,20 +388,32 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={!!(forcePaused || paused)}
|
||||
progressUpdateInterval={500}
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
currentTimeRef.current = ct;
|
||||
onTimeUpdate?.(ct);
|
||||
// 拖动进度条时跳过 UI 更新,避免与用户拖动冲突
|
||||
if (isSeekingRef.current) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate.current < 450) return;
|
||||
lastProgressUpdate.current = now;
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0 && Math.abs(dur - durationRef.current) > 1) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (initialTime && initialTime > 0) {
|
||||
videoRef.current?.seek(initialTime);
|
||||
}
|
||||
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
|
||||
if (!forcePaused) {
|
||||
setPaused(true);
|
||||
requestAnimationFrame(() => setPaused(false));
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 杜比视界播放失败时自动降级到 1080P
|
||||
|
||||
@@ -32,9 +32,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
};
|
||||
|
||||
const handleExitFullscreen = async () => {
|
||||
// 退出全屏:同步进度,竖屏一律暂停
|
||||
portraitRef.current?.seek(lastTimeRef.current);
|
||||
portraitRef.current?.setPaused(true);
|
||||
setFullscreen(false);
|
||||
if (Platform.OS !== 'web')
|
||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
@@ -71,46 +68,49 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Portrait player: always mounted, force-paused while fullscreen is active */}
|
||||
<NativeVideoPlayer
|
||||
ref={portraitRef}
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleEnterFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
isFullscreen={false}
|
||||
forcePaused={fullscreen}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
/>
|
||||
{/* 竖屏和全屏互斥渲染,避免同时挂载两个视频解码器 */}
|
||||
{!fullscreen && (
|
||||
<NativeVideoPlayer
|
||||
ref={portraitRef}
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleEnterFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
isFullscreen={false}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal visible={fullscreen} animationType="none" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={needsRotation
|
||||
? { width: height, height: width, transform: [{ rotate: '90deg' }] }
|
||||
: { flex: 1, width: '100%' }
|
||||
}>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleExitFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
danmakus={danmakus}
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||
/>
|
||||
{fullscreen && (
|
||||
<Modal visible animationType="none" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={needsRotation
|
||||
? { width: height, height: width, transform: [{ rotate: '90deg' }] }
|
||||
: { flex: 1, width: '100%' }
|
||||
}>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleExitFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
danmakus={danmakus}
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user