Files
JKVideo/app/live/[roomId].tsx
Developer 33a3148b38 feat: 直播小窗播放(PiP)功能
- 新增 store/liveStore.ts:Zustand store,存储小窗直播状态(roomId/title/cover/hlsUrl)
- 新增 components/LiveMiniPlayer.tsx:可拖动悬浮迷你直播播放器
  - 原生端实际播放 HLS 流(react-native-video,有声)
  - Web 端降级展示封面图 + LIVE 徽标
  - LIVE 红点徽标 + 标题条底部
  - 关闭按钮(×)和点击进入直播间
  - HLS 出错时自动关闭(onError → clearLive)
  - 视频 MiniPlayer 激活时自动上移避免重叠
- _layout.tsx:全局挂载 <LiveMiniPlayer />
- live/[roomId].tsx:顶部栏添加小窗按钮(browsers-outline)
  - 仅直播中且有流地址时显示,离线时占位保持 title 居中
  - 点击后 setLive + router.back(),返回首页继续看直播
  - 进入同房间时自动 clearLive() 避免双播
2026-03-25 13:16:16 +08:00

294 lines
9.2 KiB
TypeScript

import React, { useState, useEffect } 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 }>();
const router = useRouter();
const theme = useTheme();
const id = parseInt(roomId ?? "0", 10);
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 { roomId: miniRoomId, setLive, clearLive } = useLiveStore();
// 进入该直播间时,若小窗正在播放同一房间,清除小窗避免双播
useEffect(() => {
if (miniRoomId === id) {
clearLive();
}
}, [id, miniRoomId]);
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" },
});