Files
JKVideo/app/live/[roomId].tsx
Developer e3def7d01b fix: 修复小窗闭包过期、详情页 loading 卡死、视频播放器性能问题
- 小窗 PanResponder 用 storeRef 替代闭包捕获,修复 roomId/bvid 始终为初始值
- useLiveDetail 用 ref 比对替代 cancelled 标志,防止 fetch 被意外取消
- 详情页 useLayoutEffect 同步清除小窗,BigVideoCard 小窗活跃时跳过播放
- 视频播放器竖屏/全屏互斥渲染,减半解码器占用
- onProgress 节流 + 退出全屏强制恢复播放
2026-03-25 15:03:49 +08:00

294 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Image,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useLiveDetail } from "../../hooks/useLiveDetail";
import { useLiveDanmaku } from "../../hooks/useLiveDanmaku";
import { LivePlayer } from "../../components/LivePlayer";
import DanmakuList from "../../components/DanmakuList";
import { formatCount } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore";
type Tab = "intro" | "danmaku";
export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
console.log("LiveDetailScreen params:", { roomId });
const router = useRouter();
const theme = useTheme();
const id = parseInt(roomId ?? "0", 10);
// 进入详情页时立即清除小窗useLayoutEffect 在绘制前同步执行)
useLayoutEffect(() => {
useLiveStore.getState().clearLive();
}, []);
const { room, anchor, stream, loading, error, changeQuality } =
useLiveDetail(id);
const [tab, setTab] = useState<Tab>("intro");
const isLive = room?.live_status === 1;
const hlsUrl = stream?.hlsUrl ?? "";
const flvUrl = stream?.flvUrl ?? "";
const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0;
const setLive = useLiveStore(s => s.setLive);
const actualRoomId = room?.roomid ?? id;
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
{/* TopBar */}
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
{room?.title ?? "直播间"}
</Text>
{isLive && hlsUrl ? (
<TouchableOpacity
style={styles.pipBtn}
onPress={() => {
setLive(id, room?.title ?? '', room?.keyframe ?? '', hlsUrl);
router.back();
}}
>
<Ionicons name="browsers-outline" size={22} color={theme.text} />
</TouchableOpacity>
) : (
<View style={styles.pipBtn} />
)}
</View>
{/* Player */}
<LivePlayer
hlsUrl={hlsUrl}
flvUrl={flvUrl}
isLive={isLive}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
/>
{/* TabBar */}
<View style={[styles.tabBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("intro")}
>
<Text style={[styles.tabLabel, { color: theme.textSub }, tab === "intro" && styles.tabActive]}>
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("danmaku")}
>
<Text
style={[styles.tabLabel, { color: theme.textSub }, tab === "danmaku" && styles.tabActive]}
>
{danmakus.length > 0 ? ` ${danmakus.length}` : ""}
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
{/* Content */}
{loading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : error ? (
<Text style={styles.errorText}>{error}</Text>
) : (
<>
<ScrollView
style={[styles.scroll, tab !== "intro" && styles.hidden]}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}>
<Text style={[styles.title, { color: theme.text }]}>{room?.title}</Text>
<View style={styles.metaRow}>
{isLive ? (
<View style={styles.livePill}>
<View style={styles.liveDot} />
<Text style={styles.livePillText}></Text>
</View>
) : (
<View style={[styles.livePill, styles.offlinePill]}>
<Text style={[styles.livePillText, styles.offlinePillText]}>
</Text>
</View>
)}
<View style={styles.onlineRow}>
<Ionicons name="eye-outline" size={13} color="#999" />
<Text style={styles.onlineText}>
{formatCount(room?.online ?? 0)}
</Text>
</View>
</View>
<View style={styles.areaRow}>
{room?.parent_area_name ? (
<Text style={styles.areaTag}>{room.parent_area_name}</Text>
) : null}
{room?.area_name ? (
<Text style={styles.areaTag}>{room.area_name}</Text>
) : null}
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.border }]} />
{anchor && (
<View style={styles.anchorRow}>
<Image
source={{ uri: proxyImageUrl(anchor.face) }}
style={styles.avatar}
/>
<Text style={[styles.anchorName, { color: theme.text }]}>{anchor.uname}</Text>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
)}
{!!room?.description && (
<View style={styles.descBox}>
<Text style={[styles.descText, { color: theme.text }]}>{room.description}</Text>
</View>
)}
</ScrollView>
<DanmakuList
danmakus={danmakus}
currentTime={999999}
visible
onToggle={() => {}}
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
hideHeader
isLive
maxItems={500}
giftCounts={giftCounts}
/>
</>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
pipBtn: { padding: 4, width: 32, alignItems: 'center' },
topTitle: {
flex: 1,
fontSize: 15,
fontWeight: "600",
marginLeft: 4,
},
loader: { marginVertical: 30 },
errorText: { textAlign: "center", color: "#f00", padding: 20 },
tabBar: {
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
},
tabItem: {
paddingHorizontal: 20,
paddingVertical: 10,
alignItems: "center",
position: "relative",
},
tabLabel: { fontSize: 14, fontWeight: "500" },
tabActive: { color: "#00AEEC", fontWeight: "700" },
tabUnderline: {
position: "absolute",
bottom: 0,
width: 24,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 2,
},
scroll: { flex: 1 },
titleSection: { padding: 14 },
title: {
fontSize: 16,
fontWeight: "600",
lineHeight: 22,
marginBottom: 8,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginBottom: 8,
},
livePill: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff0f0",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
gap: 4,
},
offlinePill: { backgroundColor: "#f5f5f5" },
liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#f00" },
livePillText: { fontSize: 12, color: "#f00", fontWeight: "600" },
offlinePillText: { color: "#999" },
onlineRow: { flexDirection: "row", alignItems: "center", gap: 3 },
onlineText: { fontSize: 12, color: "#999" },
areaRow: { flexDirection: "row", gap: 6 },
areaTag: {
fontSize: 11,
color: "#00AEEC",
backgroundColor: "#e8f7fd",
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginHorizontal: 14,
},
anchorRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 12,
},
avatar: { width: 40, height: 40, borderRadius: 20, marginRight: 10 },
anchorName: { flex: 1, fontSize: 14, fontWeight: "500" },
followBtn: {
backgroundColor: "#00AEEC",
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 14,
},
followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
descBox: { padding: 14, paddingTop: 4 },
descText: { fontSize: 14, lineHeight: 22 },
danmakuFull: { flex: 1 },
hidden: { display: "none" },
});