fix: 修复小窗闭包过期、详情页 loading 卡死、视频播放器性能问题

- 小窗 PanResponder 用 storeRef 替代闭包捕获,修复 roomId/bvid 始终为初始值
- useLiveDetail 用 ref 比对替代 cancelled 标志,防止 fetch 被意外取消
- 详情页 useLayoutEffect 同步清除小窗,BigVideoCard 小窗活跃时跳过播放
- 视频播放器竖屏/全屏互斥渲染,减半解码器占用
- onProgress 节流 + 退出全屏强制恢复播放
This commit is contained in:
Developer
2026-03-25 15:03:49 +08:00
parent 68b8b7d665
commit e3def7d01b
11 changed files with 147 additions and 118 deletions

View File

@@ -5,6 +5,23 @@
--- ---
## [1.0.13] - 2026-03-25
### 修复
- **小窗 PanResponder 闭包过期**`useRef(PanResponder.create(...))` 捕获初始 `roomId=0` / `bvid=""`,导致点击小窗跳转到错误页面;改用 `storeRef` 模式保持最新值
- **直播小窗进入详情无限 loading**`useLiveDetail` 使用 `cancelled` 闭包标志effect cleanup 后 fetch 被静默丢弃;改用 `latestRoomId` ref 比对替代 cancelled 模式
- **进入播放器页面小窗不关闭**:视频/直播详情页进入时通过 `useLayoutEffect` + `getState().clearLive()` 同步清除小窗,避免双播和资源竞争
- **BigVideoCard 与直播小窗冲突**:首页 BigVideoCard 自动播放与直播小窗竞争解码器资源;小窗活跃时跳过 Video 渲染,仅显示封面图
- **退出全屏视频暂停**互斥渲染后竖屏播放器重新挂载react-native-video seek 后不自动恢复播放;`onLoad` 中强制 `paused` 状态切换触发播放
### 优化
- **视频播放器单实例**:竖屏/全屏互斥渲染(`{!fullscreen && ...}` / `{fullscreen && ...}`),不再同时挂载两个 Video 解码器,减半 GPU/内存占用
- **onProgress 节流**`progressUpdateInterval` 从 250ms 调为 500ms回调内增加 450ms 节流和 seeking 跳过,减少重渲染
- **移除调试日志**:清理 NativeVideoPlayer 中遗留的 `console.log`
- **下载页 UI 优化**:下载管理页交互和暗黑主题适配
---
## [1.0.12] - 2026-03-25 ## [1.0.12] - 2026-03-25
### 新增 ### 新增

View File

@@ -8,7 +8,6 @@ import {
Image, Image,
Modal, Modal,
StatusBar, StatusBar,
useWindowDimensions,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@@ -36,14 +35,10 @@ export default function DownloadsScreen() {
const [playingUri, setPlayingUri] = useState<string | null>(null); const [playingUri, setPlayingUri] = useState<string | null>(null);
const [playingTitle, setPlayingTitle] = useState(''); const [playingTitle, setPlayingTitle] = useState('');
const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null); const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null);
const [showControls, setShowControls] = useState(true);
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
async function openPlayer(uri: string, title: string) { async function openPlayer(uri: string, title: string) {
setPlayingTitle(title); setPlayingTitle(title);
setPlayingUri(uri); setPlayingUri(uri);
setShowControls(true);
await ScreenOrientation?.unlockAsync(); await ScreenOrientation?.unlockAsync();
} }
@@ -133,29 +128,23 @@ export default function DownloadsScreen() {
onRequestClose={closePlayer} onRequestClose={closePlayer}
> >
<StatusBar hidden /> <StatusBar hidden />
<TouchableOpacity <View style={styles.playerBg}>
activeOpacity={1}
style={styles.playerBg}
onPress={() => setShowControls(v => !v)}
>
{playingUri && ( {playingUri && (
<Video <Video
source={{ uri: playingUri }} source={{ uri: playingUri }}
style={isLandscape ? { width, height } : { width, height: width * 0.5625 }} style={StyleSheet.absoluteFillObject}
resizeMode="contain" resizeMode="contain"
controls={false} controls
paused={false} paused={false}
/> />
)} )}
{showControls && ( <View style={styles.playerBar}>
<View style={[styles.playerBar, isLandscape && { top: 16 }]}> <TouchableOpacity onPress={closePlayer} style={styles.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<TouchableOpacity onPress={closePlayer} style={styles.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> <Ionicons name="chevron-back" size={24} color="#fff" />
<Ionicons name="chevron-back" size={24} color="#fff" /> </TouchableOpacity>
</TouchableOpacity> <Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text>
<Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text> </View>
</View> </View>
)}
</TouchableOpacity>
</Modal> </Modal>
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import { import {
View, View,
Text, Text,
@@ -24,9 +24,16 @@ type Tab = "intro" | "danmaku";
export default function LiveDetailScreen() { export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>(); const { roomId } = useLocalSearchParams<{ roomId: string }>();
console.log("LiveDetailScreen params:", { roomId });
const router = useRouter(); const router = useRouter();
const theme = useTheme(); const theme = useTheme();
const id = parseInt(roomId ?? "0", 10); const id = parseInt(roomId ?? "0", 10);
// 进入详情页时立即清除小窗useLayoutEffect 在绘制前同步执行)
useLayoutEffect(() => {
useLiveStore.getState().clearLive();
}, []);
const { room, anchor, stream, loading, error, changeQuality } = const { room, anchor, stream, loading, error, changeQuality } =
useLiveDetail(id); useLiveDetail(id);
const [tab, setTab] = useState<Tab>("intro"); const [tab, setTab] = useState<Tab>("intro");
@@ -37,15 +44,7 @@ export default function LiveDetailScreen() {
const qualities = stream?.qualities ?? []; const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0; const currentQn = stream?.qn ?? 0;
const { setLive, clearLive } = useLiveStore(); const setLive = useLiveStore(s => s.setLive);
// 进入该直播间时,若小窗正在播放同一房间,清除小窗避免双播
// 仅在挂载时运行一次(用 getState 读值,不创建响应式依赖)
useEffect(() => {
if (useLiveStore.getState().roomId === id) {
clearLive();
}
}, []);
const actualRoomId = room?.roomid ?? id; const actualRoomId = room?.roomid ?? id;
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0); const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import { import {
View, View,
Text, Text,
@@ -23,6 +23,7 @@ import { formatCount, formatDuration } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl"; import { proxyImageUrl } from "../../utils/imageUrl";
import { DownloadSheet } from "../../components/DownloadSheet"; import { DownloadSheet } from "../../components/DownloadSheet";
import { useTheme } from "../../utils/theme"; import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore";
type Tab = "intro" | "comments" | "danmaku"; type Tab = "intro" | "comments" | "danmaku";
@@ -30,6 +31,11 @@ export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>(); const { bvid } = useLocalSearchParams<{ bvid: string }>();
const router = useRouter(); const router = useRouter();
const theme = useTheme(); const theme = useTheme();
// 进入视频详情页时立即清除直播小窗
useLayoutEffect(() => {
useLiveStore.getState().clearLive();
}, []);
const { const {
video, video,
playData, playData,

View File

@@ -23,6 +23,7 @@ import { getPlayUrl, getVideoDetail } from "../services/bilibili";
import { coverImageUrl } from "../utils/imageUrl"; import { coverImageUrl } from "../utils/imageUrl";
import { useSettingsStore } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore";
import { useTheme } from "../utils/theme"; import { useTheme } from "../utils/theme";
import { useLiveStore } from "../store/liveStore";
import { formatCount, formatDuration } from "../utils/format"; import { formatCount, formatDuration } from "../utils/format";
import type { VideoItem } from "../services/types"; import type { VideoItem } from "../services/types";
@@ -57,6 +58,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
}: Props) { }: Props) {
const { width: SCREEN_W } = useWindowDimensions(); const { width: SCREEN_W } = useWindowDimensions();
const trafficSaving = useSettingsStore(s => s.trafficSaving); const trafficSaving = useSettingsStore(s => s.trafficSaving);
const liveActive = useLiveStore(s => s.isActive);
const theme = useTheme(); const theme = useTheme();
const THUMB_H = SCREEN_W * 0.5625; const THUMB_H = SCREEN_W * 0.5625;
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H }; const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
@@ -92,7 +94,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
// Preload: fetch play URL on mount (before card is visible) // Preload: fetch play URL on mount (before card is visible)
useEffect(() => { useEffect(() => {
if (videoUrl || trafficSaving) return; if (videoUrl || trafficSaving || liveActive) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -127,8 +129,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
// Pause/resume based on visibility and scroll state // Pause/resume based on visibility and scroll state
useEffect(() => { useEffect(() => {
if (!videoUrl) return; if (!videoUrl) return;
if (!isVisible || trafficSaving) { if (!isVisible || trafficSaving || liveActive) {
// Off-screen or traffic saving: pause, mute, show thumbnail
setPaused(true); setPaused(true);
setMuted(true); setMuted(true);
Animated.timing(thumbOpacity, { Animated.timing(thumbOpacity, {
@@ -137,10 +138,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
} else if (isScrolling) { } else if (isScrolling) {
// Visible but scrolling: just pause (keep thumbnail hidden, keep mute state)
setPaused(true); setPaused(true);
} else { } else {
// Visible and not scrolling: play, fade out thumbnail
setPaused(false); setPaused(false);
Animated.timing(thumbOpacity, { Animated.timing(thumbOpacity, {
toValue: 0, toValue: 0,
@@ -148,10 +147,10 @@ export const BigVideoCard = React.memo(function BigVideoCard({
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
} }
}, [isVisible, isScrolling, videoUrl, trafficSaving]); }, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]);
const handleVideoReady = () => { const handleVideoReady = () => {
if (!isVisible || isScrolling || trafficSaving) return; if (!isVisible || isScrolling || trafficSaving || liveActive) return;
setPaused(false); setPaused(false);
Animated.timing(thumbOpacity, { Animated.timing(thumbOpacity, {
toValue: 0, toValue: 0,
@@ -229,7 +228,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
{/* Media area */} {/* Media area */}
<View style={[mediaDimensions, { position: "relative" }]}> <View style={[mediaDimensions, { position: "relative" }]}>
{/* Video player — rendered first so it sits behind the thumbnail */} {/* Video player — rendered first so it sits behind the thumbnail */}
{videoUrl && ( {videoUrl && !liveActive && (
<Video <Video
ref={videoRef} ref={videoRef}
source={ source={

View File

@@ -222,7 +222,7 @@ export default function DanmakuList({
{ opacity: item._fadeAnim, borderBottomColor: theme.border }, { 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}> <View style={liveStyles.msgBody}>
{guard && ( {guard && (
<View <View
@@ -242,7 +242,9 @@ export default function DanmakuList({
<View style={liveStyles.medalTag}> <View style={liveStyles.medalTag}>
<Text style={liveStyles.medalName}>{item.medalName}</Text> <Text style={liveStyles.medalName}>{item.medalName}</Text>
<View style={liveStyles.medalLvBox}> <View style={liveStyles.medalLvBox}>
<Text style={liveStyles.medalLv}>{item.medalLevel}</Text> <Text style={[liveStyles.medalLv, { color: theme.text }]}>
{item.medalLevel}
</Text>
</View> </View>
</View> </View>
)} )}
@@ -257,7 +259,6 @@ export default function DanmakuList({
{item.text} {item.text}
</Text> </Text>
</View> </View>
</Animated.View> </Animated.View>
); );
}, },
@@ -451,7 +452,7 @@ const liveStyles = StyleSheet.create({
row: { row: {
flexDirection: "row", flexDirection: "row",
alignItems: "flex-start", alignItems: "flex-start",
justifyContent:"space-between", justifyContent: "space-between",
paddingVertical: 5, paddingVertical: 5,
}, },
time: { time: {

View File

@@ -52,6 +52,10 @@ export function LiveMiniPlayer() {
const pan = useRef(new Animated.ValueXY()).current; const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false); const isDragging = useRef(false);
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
const storeRef = useRef({ roomId, clearLive, router });
storeRef.current = { roomId, clearLive, router };
const panResponder = useRef( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
@@ -71,10 +75,11 @@ export function LiveMiniPlayer() {
pan.flattenOffset(); pan.flattenOffset();
if (!isDragging.current) { if (!isDragging.current) {
const { locationX, locationY } = evt.nativeEvent; const { locationX, locationY } = evt.nativeEvent;
const { roomId: rid, clearLive: clear, router: r } = storeRef.current;
if (locationX > MINI_W - 28 && locationY < 28) { if (locationX > MINI_W - 28 && locationY < 28) {
clearLive(); clear();
} else { } else {
router.push(`/live/${roomId}` as any); r.push(`/live/${rid}` as any);
} }
return; return;
} }

View File

@@ -19,9 +19,12 @@ export function MiniPlayer() {
const pan = useRef(new Animated.ValueXY()).current; const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false); const isDragging = useRef(false);
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
const storeRef = useRef({ bvid, clearVideo, router });
storeRef.current = { bvid, clearVideo, router };
const panResponder = useRef( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
// 从 start 阶段即抢占响应权,确保拖动可靠触发
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => { onPanResponderGrant: () => {
isDragging.current = false; isDragging.current = false;
@@ -38,16 +41,15 @@ export function MiniPlayer() {
onPanResponderRelease: (evt) => { onPanResponderRelease: (evt) => {
pan.flattenOffset(); pan.flattenOffset();
if (!isDragging.current) { if (!isDragging.current) {
// 点击:通过坐标判断是关闭还是跳转
const { locationX, locationY } = evt.nativeEvent; const { locationX, locationY } = evt.nativeEvent;
const { bvid: vid, clearVideo: clear, router: r } = storeRef.current;
if (locationX > MINI_W - 28 && locationY < 28) { if (locationX > MINI_W - 28 && locationY < 28) {
clearVideo(); clear();
} else { } else {
router.push(`/video/${bvid}` as any); r.push(`/video/${vid}` as any);
} }
return; return;
} }
// 拖动:吸附到最近边缘
const { width: sw, height: sh } = Dimensions.get('window'); const { width: sw, height: sh } = Dimensions.get('window');
const curX = (pan.x as any)._value; const curX = (pan.x as any)._value;
const curY = (pan.y as any)._value; const curY = (pan.y as any)._value;

View File

@@ -109,8 +109,10 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const currentTimeRef = useRef(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const durationRef = useRef(0); const durationRef = useRef(0);
const lastProgressUpdate = useRef(0);
const [showQuality, setShowQuality] = useState(false); const [showQuality, setShowQuality] = useState(false);
@@ -318,16 +320,6 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
const local = frameIdx % framesPerSheet; const local = frameIdx % framesPerSheet;
const col = local % img_x_len; const col = local % img_x_len;
const row = Math.floor(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 scale = THUMB_DISPLAY_W / TW;
const DW = THUMB_DISPLAY_W; const DW = THUMB_DISPLAY_W;
@@ -396,20 +388,32 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
resizeMode="contain" resizeMode="contain"
controls={false} controls={false}
paused={!!(forcePaused || paused)} paused={!!(forcePaused || paused)}
progressUpdateInterval={500}
onProgress={({ onProgress={({
currentTime: ct, currentTime: ct,
seekableDuration: dur, seekableDuration: dur,
playableDuration: buf, playableDuration: buf,
}) => { }) => {
setCurrentTime(ct); currentTimeRef.current = ct;
if (dur > 0) setDuration(dur);
setBuffered(buf);
onTimeUpdate?.(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={() => { onLoad={() => {
if (initialTime && initialTime > 0) { if (initialTime && initialTime > 0) {
videoRef.current?.seek(initialTime); videoRef.current?.seek(initialTime);
} }
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
if (!forcePaused) {
setPaused(true);
requestAnimationFrame(() => setPaused(false));
}
}} }}
onError={(e) => { onError={(e) => {
// 杜比视界播放失败时自动降级到 1080P // 杜比视界播放失败时自动降级到 1080P

View File

@@ -32,9 +32,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
}; };
const handleExitFullscreen = async () => { const handleExitFullscreen = async () => {
// 退出全屏:同步进度,竖屏一律暂停
portraitRef.current?.seek(lastTimeRef.current);
portraitRef.current?.setPaused(true);
setFullscreen(false); setFullscreen(false);
if (Platform.OS !== 'web') if (Platform.OS !== 'web')
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
@@ -71,46 +68,49 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
return ( return (
<> <>
{/* Portrait player: always mounted, force-paused while fullscreen is active */} {/* 竖屏和全屏互斥渲染,避免同时挂载两个视频解码器 */}
<NativeVideoPlayer {!fullscreen && (
ref={portraitRef} <NativeVideoPlayer
playData={playData} ref={portraitRef}
qualities={qualities} playData={playData}
currentQn={currentQn} qualities={qualities}
onQualityChange={onQualityChange} currentQn={currentQn}
onFullscreen={handleEnterFullscreen} onQualityChange={onQualityChange}
bvid={bvid} onFullscreen={handleEnterFullscreen}
cid={cid} bvid={bvid}
isFullscreen={false} cid={cid}
forcePaused={fullscreen} isFullscreen={false}
initialTime={lastTimeRef.current} initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }} onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
/> />
)}
<Modal visible={fullscreen} animationType="none" statusBarTranslucent> {fullscreen && (
<StatusBar hidden /> <Modal visible animationType="none" statusBarTranslucent>
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}> <StatusBar hidden />
<View style={needsRotation <View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
? { width: height, height: width, transform: [{ rotate: '90deg' }] } <View style={needsRotation
: { flex: 1, width: '100%' } ? { width: height, height: width, transform: [{ rotate: '90deg' }] }
}> : { flex: 1, width: '100%' }
<NativeVideoPlayer }>
playData={playData} <NativeVideoPlayer
qualities={qualities} playData={playData}
currentQn={currentQn} qualities={qualities}
onQualityChange={onQualityChange} currentQn={currentQn}
onFullscreen={handleExitFullscreen} onQualityChange={onQualityChange}
bvid={bvid} onFullscreen={handleExitFullscreen}
cid={cid} bvid={bvid}
danmakus={danmakus} cid={cid}
isFullscreen={true} danmakus={danmakus}
initialTime={lastTimeRef.current} isFullscreen={true}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }} initialTime={lastTimeRef.current}
style={needsRotation ? { width: height, height: width } : { flex: 1 }} onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
/> style={needsRotation ? { width: height, height: width } : { flex: 1 }}
/>
</View>
</View> </View>
</View> </Modal>
</Modal> )}
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili'; import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili';
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types'; import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
@@ -19,35 +19,42 @@ export function useLiveDetail(roomId: number) {
error: null, error: null,
}); });
// 用 ref 追踪最新的 roomId避免 cancelled 闭包问题
const latestRoomId = useRef(roomId);
latestRoomId.current = roomId;
useEffect(() => { useEffect(() => {
if (!roomId) return; if (!roomId) return;
let cancelled = false;
setState({ room: null, anchor: null, stream: null, loading: true, error: null }); setState({ room: null, anchor: null, stream: null, loading: true, error: null });
async function fetch() { const fetchId = roomId; // 捕获当前 roomId
async function doFetch() {
try { try {
const [room, anchor] = await Promise.all([ const [room, anchor] = await Promise.all([
getLiveRoomDetail(roomId), getLiveRoomDetail(fetchId),
getLiveAnchorInfo(roomId), getLiveAnchorInfo(fetchId),
]); ]);
if (cancelled) return;
// 仅在 roomId 未变化时更新状态(替代 cancelled 模式)
if (latestRoomId.current !== fetchId) return;
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] }; let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
if (room.live_status === 1) { if (room?.live_status === 1) {
stream = await getLiveStreamUrl(roomId); stream = await getLiveStreamUrl(fetchId);
} }
if (cancelled) return;
if (latestRoomId.current !== fetchId) return;
setState({ room, anchor, stream, loading: false, error: null }); setState({ room, anchor, stream, loading: false, error: null });
} catch (e: any) { } catch (e: any) {
if (cancelled) return; if (latestRoomId.current !== fetchId) return;
setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' })); setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' }));
} }
} }
fetch(); doFetch();
return () => { cancelled = true; };
}, [roomId]); }, [roomId]);
const changeQuality = useCallback(async (qn: number) => { const changeQuality = useCallback(async (qn: number) => {