mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
直播弹幕与界面优化
- useLiveDanmaku: 重写协议解析,支持 zlib 压缩消息、token 认证、base64 帧处理 - services/bilibili: 新增 getLiveDanmakuInfo 获取弹幕服务器信息 - live/[roomId]: 使用真实 roomid 而非 URL 别名连接弹幕 - NativeVideoPlayer: 增加中文注释,移除调试日志 - video/[bvid]: UP主头像移至标题前,调整字号与样式 - index: 替换 logo 文字为下载按钮图标 - videoRows: 修复奇数视频配对,调整 BigRow 位置策略 - package.json: 移除未使用的 fast-xml-parser 和 xml2js 依赖
This commit is contained in:
@@ -432,7 +432,9 @@ export default function HomeScreen() {
|
|||||||
<Ionicons name="search" size={14} color="#999" />
|
<Ionicons name="search" size={14} color="#999" />
|
||||||
<Text style={styles.searchPlaceholder}>搜索视频、UP主...</Text>
|
<Text style={styles.searchPlaceholder}>搜索视频、UP主...</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.logo}>哔</Text>
|
<TouchableOpacity style={styles.downloadBtn} activeOpacity={0.7}>
|
||||||
|
<Ionicons name="cloud-download-outline" size={24} color="#999" />
|
||||||
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
@@ -506,6 +508,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
gap: 5,
|
gap: 5,
|
||||||
},
|
},
|
||||||
|
downloadBtn: {},
|
||||||
searchPlaceholder: {
|
searchPlaceholder: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: "#999",
|
color: "#999",
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ export default function LiveDetailScreen() {
|
|||||||
const qualities = stream?.qualities ?? [];
|
const qualities = stream?.qualities ?? [];
|
||||||
const currentQn = stream?.qn ?? 0;
|
const currentQn = stream?.qn ?? 0;
|
||||||
|
|
||||||
const danmakus = useLiveDanmaku(isLive ? id : 0);
|
// Use actual roomid from room detail (not the short/alias ID from the URL)
|
||||||
|
const actualRoomId = room?.roomid ?? id;
|
||||||
|
const danmakus = useLiveDanmaku(isLive ? actualRoomId : 0);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={styles.safe}>
|
||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
|
|||||||
@@ -99,21 +99,38 @@ export default function VideoDetailScreen() {
|
|||||||
{/* TabBar — sits directly below player, always visible once video loads */}
|
{/* TabBar — sits directly below player, always visible once video loads */}
|
||||||
{video && (
|
{video && (
|
||||||
<View style={styles.tabBar}>
|
<View style={styles.tabBar}>
|
||||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab("intro")}>
|
<TouchableOpacity
|
||||||
<Text style={[styles.tabLabel, tab === "intro" && styles.tabActive]}>
|
style={styles.tabItem}
|
||||||
|
onPress={() => setTab("intro")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.tabLabel, tab === "intro" && styles.tabActive]}
|
||||||
|
>
|
||||||
简介
|
简介
|
||||||
</Text>
|
</Text>
|
||||||
{tab === "intro" && <View style={styles.tabUnderline} />}
|
{tab === "intro" && <View style={styles.tabUnderline} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab("comments")}>
|
<TouchableOpacity
|
||||||
<Text style={[styles.tabLabel, tab === "comments" && styles.tabActive]}>
|
style={styles.tabItem}
|
||||||
评论{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
|
onPress={() => setTab("comments")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.tabLabel, tab === "comments" && styles.tabActive]}
|
||||||
|
>
|
||||||
|
评论
|
||||||
|
{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
{tab === "comments" && <View style={styles.tabUnderline} />}
|
{tab === "comments" && <View style={styles.tabUnderline} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab("danmaku")}>
|
<TouchableOpacity
|
||||||
<Text style={[styles.tabLabel, tab === "danmaku" && styles.tabActive]}>
|
style={styles.tabItem}
|
||||||
弹幕{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
|
onPress={() => setTab("danmaku")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.tabLabel, tab === "danmaku" && styles.tabActive]}
|
||||||
|
>
|
||||||
|
弹幕
|
||||||
|
{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
{tab === "danmaku" && <View style={styles.tabUnderline} />}
|
{tab === "danmaku" && <View style={styles.tabUnderline} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -126,16 +143,10 @@ export default function VideoDetailScreen() {
|
|||||||
) : video ? (
|
) : video ? (
|
||||||
tab === "intro" ? (
|
tab === "intro" ? (
|
||||||
// 简介:视频信息 + 合集 + 简介文本
|
// 简介:视频信息 + 合集 + 简介文本
|
||||||
<ScrollView style={styles.tabScroll} showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
<View style={styles.titleSection}>
|
style={styles.tabScroll}
|
||||||
<Text style={styles.title}>{video.title}</Text>
|
showsVerticalScrollIndicator={false}
|
||||||
<View style={styles.statsRow}>
|
>
|
||||||
<StatBadge icon="play" count={video.stat.view} />
|
|
||||||
<StatBadge icon="heart" count={video.stat.like} />
|
|
||||||
<StatBadge icon="star" count={video.stat.favorite} />
|
|
||||||
<StatBadge icon="chatbubble" count={video.stat.reply} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.upRow}>
|
<View style={styles.upRow}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: proxyImageUrl(video.owner.face) }}
|
source={{ uri: proxyImageUrl(video.owner.face) }}
|
||||||
@@ -146,6 +157,15 @@ export default function VideoDetailScreen() {
|
|||||||
<Text style={styles.followTxt}>+ 关注</Text>
|
<Text style={styles.followTxt}>+ 关注</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.titleSection}>
|
||||||
|
<Text style={styles.title}>{video.title}</Text>
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<StatBadge icon="play" count={video.stat.view} />
|
||||||
|
<StatBadge icon="heart" count={video.stat.like} />
|
||||||
|
<StatBadge icon="star" count={video.stat.favorite} />
|
||||||
|
<StatBadge icon="chatbubble" count={video.stat.reply} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
{video.ugc_season && (
|
{video.ugc_season && (
|
||||||
<SeasonSection
|
<SeasonSection
|
||||||
season={video.ugc_season}
|
season={video.ugc_season}
|
||||||
@@ -171,23 +191,45 @@ export default function VideoDetailScreen() {
|
|||||||
data={comments}
|
data={comments}
|
||||||
keyExtractor={(c) => String(c.rpid)}
|
keyExtractor={(c) => String(c.rpid)}
|
||||||
renderItem={({ item }) => <CommentItem item={item} />}
|
renderItem={({ item }) => <CommentItem item={item} />}
|
||||||
onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
|
onEndReached={() => {
|
||||||
|
if (cmtHasMore && !cmtLoading) loadComments();
|
||||||
|
}}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={styles.sortRow}>
|
<View style={styles.sortRow}>
|
||||||
<Text style={styles.sortLabel}>排序</Text>
|
<Text style={styles.sortLabel}>排序</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.sortBtn, commentSort === 2 && styles.sortBtnActive]}
|
style={[
|
||||||
|
styles.sortBtn,
|
||||||
|
commentSort === 2 && styles.sortBtnActive,
|
||||||
|
]}
|
||||||
onPress={() => setCommentSort(2)}
|
onPress={() => setCommentSort(2)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.sortBtnTxt, commentSort === 2 && styles.sortBtnTxtActive]}>热门</Text>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sortBtnTxt,
|
||||||
|
commentSort === 2 && styles.sortBtnTxtActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
热门
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.sortBtn, commentSort === 0 && styles.sortBtnActive]}
|
style={[
|
||||||
|
styles.sortBtn,
|
||||||
|
commentSort === 0 && styles.sortBtnActive,
|
||||||
|
]}
|
||||||
onPress={() => setCommentSort(0)}
|
onPress={() => setCommentSort(0)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.sortBtnTxt, commentSort === 0 && styles.sortBtnTxtActive]}>最新</Text>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sortBtnTxt,
|
||||||
|
commentSort === 0 && styles.sortBtnTxtActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
最新
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
@@ -312,9 +354,13 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
miniBtn: { padding: 4 },
|
miniBtn: { padding: 4 },
|
||||||
loader: { marginVertical: 30 },
|
loader: { marginVertical: 30 },
|
||||||
titleSection: { padding: 14 },
|
titleSection: {
|
||||||
|
padding: 14,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 16,
|
fontSize: 13,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#212121",
|
color: "#212121",
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
@@ -326,20 +372,19 @@ const styles = StyleSheet.create({
|
|||||||
upRow: {
|
upRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 10,
|
||||||
paddingBottom: 12,
|
paddingBottom: 0,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
paddingTop: 12,
|
||||||
borderBottomColor: "#f0f0f0",
|
|
||||||
},
|
},
|
||||||
avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 },
|
avatar: { width: 48, height: 48, borderRadius: 19, marginRight: 10 },
|
||||||
upName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" },
|
upName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" },
|
||||||
followBtn: {
|
followBtn: {
|
||||||
backgroundColor: "#00AEEC",
|
backgroundColor: "#00AEEC",
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 5,
|
paddingVertical: 3,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
},
|
},
|
||||||
followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
|
followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" },
|
||||||
seasonBox: {
|
seasonBox: {
|
||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: "#f0f0f0",
|
borderTopColor: "#f0f0f0",
|
||||||
@@ -384,8 +429,8 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
tabLabel: { fontSize: 14, color: "#999" },
|
tabLabel: { fontSize: 13, color: "#999" },
|
||||||
tabActive: { color: "#00AEEC", fontWeight: "700" },
|
tabActive: { color: "#00AEEC" },
|
||||||
tabUnderline: {
|
tabUnderline: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ const HEADERS = {
|
|||||||
function clamp(v: number, lo: number, hi: number) {
|
function clamp(v: number, lo: number, hi: number) {
|
||||||
return Math.max(lo, Math.min(hi, v));
|
return Math.max(lo, Math.min(hi, v));
|
||||||
}
|
}
|
||||||
|
//
|
||||||
// Binary search in shots index to find frame number for given seek time
|
|
||||||
function findFrameByTime(index: number[], seekTime: number): number {
|
function findFrameByTime(index: number[], seekTime: number): number {
|
||||||
let lo = 0, hi = index.length - 1;
|
let lo = 0,
|
||||||
|
hi = index.length - 1;
|
||||||
while (lo < hi) {
|
while (lo < hi) {
|
||||||
const mid = (lo + hi + 1) >> 1;
|
const mid = (lo + hi + 1) >> 1;
|
||||||
if (index[mid] <= seekTime) lo = mid;
|
if (index[mid] <= seekTime) lo = mid;
|
||||||
@@ -51,7 +51,6 @@ function findFrameByTime(index: number[], seekTime: number): number {
|
|||||||
return lo;
|
return lo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
playData: PlayUrlResponse | null;
|
playData: PlayUrlResponse | null;
|
||||||
qualities: { qn: number; desc: string }[];
|
qualities: { qn: number; desc: string }[];
|
||||||
@@ -117,7 +116,7 @@ export function NativeVideoPlayer({
|
|||||||
qualities.find((q) => q.qn === currentQn)?.desc ??
|
qualities.find((q) => q.qn === currentQn)?.desc ??
|
||||||
String(currentQn || "HD");
|
String(currentQn || "HD");
|
||||||
|
|
||||||
// URL resolution
|
// 解析播放链接,dash 需要构建 mpd uri,普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playData) {
|
if (!playData) {
|
||||||
setResolvedUrl(undefined);
|
setResolvedUrl(undefined);
|
||||||
@@ -131,8 +130,7 @@ export function NativeVideoPlayer({
|
|||||||
setResolvedUrl(playData.durl?.[0]?.url);
|
setResolvedUrl(playData.durl?.[0]?.url);
|
||||||
}
|
}
|
||||||
}, [playData, currentQn]);
|
}, [playData, currentQn]);
|
||||||
|
// 获取视频截图数据,供进度条预览使用。依赖 bvid 和 cid,确保在视频切换时重新获取截图。使用 cancelled 标志避免在组件卸载后更新状态,防止内存泄漏和潜在的错误。
|
||||||
// Video shots (thumbnails for seek preview)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bvid || !cid) return;
|
if (!bvid || !cid) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -140,7 +138,6 @@ export function NativeVideoPlayer({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (shotData?.image?.length) {
|
if (shotData?.image?.length) {
|
||||||
setShots(shotData);
|
setShots(shotData);
|
||||||
console.log(shotData.index,shotData.image,'缩略图长度')
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -152,18 +149,20 @@ export function NativeVideoPlayer({
|
|||||||
durationRef.current = duration;
|
durationRef.current = duration;
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
|
// 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
|
||||||
const resetHideTimer = useCallback(() => {
|
const resetHideTimer = useCallback(() => {
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
if (!isSeekingRef.current) {
|
if (!isSeekingRef.current) {
|
||||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
|
||||||
const showAndReset = useCallback(() => {
|
const showAndReset = useCallback(() => {
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
resetHideTimer();
|
resetHideTimer();
|
||||||
}, [resetHideTimer]);
|
}, [resetHideTimer]);
|
||||||
|
|
||||||
|
// 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
|
||||||
const handleTap = useCallback(() => {
|
const handleTap = useCallback(() => {
|
||||||
setShowControls((prev) => {
|
setShowControls((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@@ -175,7 +174,7 @@ export function NativeVideoPlayer({
|
|||||||
});
|
});
|
||||||
}, [resetHideTimer]);
|
}, [resetHideTimer]);
|
||||||
|
|
||||||
// Start hide timer on mount
|
// 组件卸载时清理隐藏计时器,避免内存泄漏和潜在的状态更新错误。依赖项为空数组确保只在挂载和卸载时执行一次。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetHideTimer();
|
resetHideTimer();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -191,7 +190,7 @@ export function NativeVideoPlayer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
@@ -218,6 +217,7 @@ export function NativeVideoPlayer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
|
||||||
onPanResponderRelease: (_, gs) => {
|
onPanResponderRelease: (_, gs) => {
|
||||||
if (rafRef.current) {
|
if (rafRef.current) {
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
@@ -253,7 +253,7 @@ export function NativeVideoPlayer({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).current;
|
).current;
|
||||||
|
// 进度条上触摸位置对应的时间点比例,0-1。非拖动状态为 null
|
||||||
const touchRatio =
|
const touchRatio =
|
||||||
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
||||||
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||||
@@ -274,9 +274,9 @@ export function NativeVideoPlayer({
|
|||||||
const framesPerSheet = img_x_len * img_y_len;
|
const framesPerSheet = img_x_len * img_y_len;
|
||||||
const totalFrames = framesPerSheet * image.length;
|
const totalFrames = framesPerSheet * image.length;
|
||||||
const seekTime = touchRatio * duration;
|
const seekTime = touchRatio * duration;
|
||||||
// index[i] = timestamp of frame i (seconds). Binary search returns position i (= frame number).
|
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
|
||||||
// Cap to index.length-1 to avoid black padding frames beyond valid content.
|
const frameIdx =
|
||||||
const frameIdx = index?.length && duration > 0
|
index?.length && duration > 0
|
||||||
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
|
||||||
: clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1);
|
: clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1);
|
||||||
|
|
||||||
@@ -284,22 +284,27 @@ export function NativeVideoPlayer({
|
|||||||
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]", {
|
||||||
console.log('[thumb]', { seekTime, duration, indexLen: index?.length, frameIdx, totalFrames, sheetIdx, col, row });
|
seekTime,
|
||||||
|
duration,
|
||||||
// Scale sprite frame to display size
|
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;
|
||||||
const DH = Math.round(TH * scale);
|
const DH = Math.round(TH * scale);
|
||||||
|
|
||||||
const trackLeft = barOffsetX.current;
|
const trackLeft = barOffsetX.current;
|
||||||
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
|
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
|
||||||
|
// 兼容处理图床地址,确保以 http(s) 协议开头
|
||||||
// Protocol-relative URLs from B站 API need explicit https:
|
|
||||||
const sheetUrl = image[sheetIdx].startsWith("//")
|
const sheetUrl = image[sheetIdx].startsWith("//")
|
||||||
? `https:${image[sheetIdx]}`
|
? `https:${image[sheetIdx]}`
|
||||||
: image[sheetIdx];
|
: image[sheetIdx];
|
||||||
console.log(sheetUrl,'sheetUrlsheetUrl')
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
|
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
|
||||||
@@ -378,7 +383,6 @@ export function NativeVideoPlayer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Permanent transparent tap layer — always above Video so taps always reach it */}
|
|
||||||
<TouchableWithoutFeedback onPress={handleTap}>
|
<TouchableWithoutFeedback onPress={handleTap}>
|
||||||
<View style={StyleSheet.absoluteFill} />
|
<View style={StyleSheet.absoluteFill} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
@@ -401,7 +405,6 @@ export function NativeVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
{/* Center play/pause */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.centerBtn}
|
style={styles.centerBtn}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -418,13 +421,11 @@ export function NativeVideoPlayer({
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Bottom bar */}
|
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
||||||
style={styles.bottomBar}
|
style={styles.bottomBar}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
>
|
>
|
||||||
{/* Progress track */}
|
|
||||||
<View
|
<View
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
style={styles.trackWrapper}
|
style={styles.trackWrapper}
|
||||||
@@ -432,7 +433,6 @@ export function NativeVideoPlayer({
|
|||||||
{...panResponder.panHandlers}
|
{...panResponder.panHandlers}
|
||||||
>
|
>
|
||||||
<View style={styles.track}>
|
<View style={styles.track}>
|
||||||
{/* Buffered: lighter white overlay on top of base */}
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.trackLayer,
|
styles.trackLayer,
|
||||||
@@ -442,7 +442,6 @@ export function NativeVideoPlayer({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{/* Played: accent color on top */}
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.trackLayer,
|
styles.trackLayer,
|
||||||
@@ -470,8 +469,8 @@ export function NativeVideoPlayer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
{/* Controls */}
|
||||||
|
|
||||||
{/* Controls row */}
|
|
||||||
<View style={styles.ctrlRow}>
|
<View style={styles.ctrlRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -517,10 +516,8 @@ export function NativeVideoPlayer({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thumbnail preview — absolute on container to avoid clipping */}
|
|
||||||
{renderThumbnail()}
|
{renderThumbnail()}
|
||||||
|
{/* 选清晰度 */}
|
||||||
{/* Quality modal */}
|
|
||||||
<Modal visible={showQuality} transparent animationType="fade">
|
<Modal visible={showQuality} transparent animationType="fade">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.modalOverlay}
|
style={styles.modalOverlay}
|
||||||
|
|||||||
@@ -1,94 +1,192 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import pako from 'pako';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { getLiveDanmakuInfo } from '../services/bilibili';
|
||||||
import type { DanmakuItem } from '../services/types';
|
import type { DanmakuItem } from '../services/types';
|
||||||
|
|
||||||
function buildPacket(body: string, op: number): ArrayBuffer {
|
// Read big-endian values directly from Uint8Array (avoids DataView offset issues)
|
||||||
const bodyBytes = new TextEncoder().encode(body);
|
function r32(b: Uint8Array, o: number): number {
|
||||||
const total = 16 + bodyBytes.length;
|
return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
|
||||||
const buf = new ArrayBuffer(total);
|
}
|
||||||
const view = new DataView(buf);
|
function r16(b: Uint8Array, o: number): number {
|
||||||
view.setUint32(0, total, false); // total_len
|
return ((b[o] << 8) | b[o + 1]) >>> 0;
|
||||||
view.setUint16(4, 16, false); // header_len
|
|
||||||
view.setUint16(6, 0, false); // ver=0 (no compression)
|
|
||||||
view.setUint32(8, op, false); // op
|
|
||||||
view.setUint32(12, 1, false); // seq
|
|
||||||
new Uint8Array(buf).set(bodyBytes, 16);
|
|
||||||
return buf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePackets(buf: ArrayBuffer): { op: number; body: string }[] {
|
// Build packet using raw bit ops – avoids DataView / TextEncoder quirks in Hermes.
|
||||||
const packets: { op: number; body: string }[] = [];
|
// Returns Uint8Array so ws.send() takes the ArrayBufferView path directly.
|
||||||
|
function buildPacket(body: string, op: number): Uint8Array {
|
||||||
|
const n = body.length; // JSON is ASCII-safe
|
||||||
|
const total = 16 + n;
|
||||||
|
const pkt = new Uint8Array(total);
|
||||||
|
// total_len (big-endian uint32)
|
||||||
|
pkt[0] = (total >>> 24) & 0xff;
|
||||||
|
pkt[1] = (total >>> 16) & 0xff;
|
||||||
|
pkt[2] = (total >>> 8) & 0xff;
|
||||||
|
pkt[3] = total & 0xff;
|
||||||
|
// header_len = 16 (big-endian uint16)
|
||||||
|
pkt[4] = 0x00; pkt[5] = 0x10;
|
||||||
|
// ver = 1
|
||||||
|
pkt[6] = 0x00; pkt[7] = 0x01;
|
||||||
|
// op (big-endian uint32)
|
||||||
|
pkt[8] = (op >>> 24) & 0xff;
|
||||||
|
pkt[9] = (op >>> 16) & 0xff;
|
||||||
|
pkt[10] = (op >>> 8) & 0xff;
|
||||||
|
pkt[11] = op & 0xff;
|
||||||
|
// seq = 1
|
||||||
|
pkt[12] = 0x00; pkt[13] = 0x00; pkt[14] = 0x00; pkt[15] = 0x01;
|
||||||
|
// body (ASCII chars)
|
||||||
|
for (let i = 0; i < n; i++) pkt[16 + i] = body.charCodeAt(i) & 0xff;
|
||||||
|
return pkt;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawPacket {
|
||||||
|
op: number;
|
||||||
|
ver: number;
|
||||||
|
body: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawPackets(buf: Uint8Array): RawPacket[] {
|
||||||
|
const packets: RawPacket[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
const view = new DataView(buf);
|
while (offset + 16 <= buf.length) {
|
||||||
while (offset + 16 <= buf.byteLength) {
|
const totalLen = r32(buf, offset);
|
||||||
const totalLen = view.getUint32(offset, false);
|
const headerLen = r16(buf, offset + 4);
|
||||||
const headerLen = view.getUint16(offset + 4, false);
|
const ver = r16(buf, offset + 6);
|
||||||
const op = view.getUint32(offset + 8, false);
|
const op = r32(buf, offset + 8);
|
||||||
if (totalLen < headerLen || offset + totalLen > buf.byteLength) break;
|
if (totalLen < headerLen || offset + totalLen > buf.length) break;
|
||||||
const bodyLen = totalLen - headerLen;
|
packets.push({ op, ver, body: buf.slice(offset + headerLen, offset + totalLen) });
|
||||||
const bodyBytes = new Uint8Array(buf, offset + headerLen, bodyLen);
|
|
||||||
const body = new TextDecoder('utf-8').decode(bodyBytes);
|
|
||||||
packets.push({ op, body });
|
|
||||||
offset += totalLen;
|
offset += totalLen;
|
||||||
}
|
}
|
||||||
return packets;
|
return packets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDanmaku(buf: Uint8Array): DanmakuItem[] {
|
||||||
|
const result: DanmakuItem[] = [];
|
||||||
|
for (const pkt of parseRawPackets(buf)) {
|
||||||
|
if (pkt.op !== 5) continue;
|
||||||
|
if (pkt.ver === 2) {
|
||||||
|
// ver=2: zlib-compressed JSON array of messages
|
||||||
|
try {
|
||||||
|
result.push(...extractDanmaku(pako.inflate(pkt.body)));
|
||||||
|
} catch { /* ignore decompression errors */ }
|
||||||
|
} else {
|
||||||
|
// ver=0 or ver=1: raw JSON
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(new TextDecoder().decode(pkt.body));
|
||||||
|
if (msg.cmd === 'DANMU_MSG') {
|
||||||
|
const info = msg.info;
|
||||||
|
const text = info[1] as string;
|
||||||
|
// color is at info[0][2] (decimal 0xRRGGBB)
|
||||||
|
const color = (info[0]?.[2] as number) ?? 0xffffff;
|
||||||
|
result.push({ time: 0, mode: 1, fontSize: 25, color, text });
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize message data to Uint8Array.
|
||||||
|
// React Native may deliver binary WebSocket frames as base64 strings instead of ArrayBuffer.
|
||||||
|
function toBytes(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
const bStr = atob(data);
|
||||||
|
const bytes = new Uint8Array(bStr.length);
|
||||||
|
for (let i = 0; i < bStr.length; i++) bytes[i] = bStr.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useLiveDanmaku(roomId: number): DanmakuItem[] {
|
export function useLiveDanmaku(roomId: number): DanmakuItem[] {
|
||||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
setDanmakus([]);
|
setDanmakus([]);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const ws = new WebSocket('wss://broadcastlv.chat.bilibili.com/sub');
|
async function connect() {
|
||||||
ws.binaryType = 'arraybuffer';
|
let token = '';
|
||||||
|
let host = 'wss://broadcastlv.chat.bilibili.com/sub';
|
||||||
|
try {
|
||||||
|
const info = await getLiveDanmakuInfo(roomId);
|
||||||
|
token = info.token;
|
||||||
|
host = info.host;
|
||||||
|
console.log('[danmaku] getDanmuInfo ok, host:', host, 'token:', token.slice(0, 10) + '...');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[danmaku] getDanmuInfo failed:', e);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Bilibili requires buvid3 cookie for auth; React Native doesn't send cookies
|
||||||
|
// automatically, so we pass it via the non-standard options.headers argument.
|
||||||
|
const [buvid3, sessdata] = await Promise.all([
|
||||||
|
AsyncStorage.getItem('buvid3'),
|
||||||
|
AsyncStorage.getItem('SESSDATA'),
|
||||||
|
]);
|
||||||
|
const cookieParts = buvid3 ? [`buvid3=${buvid3}`] : [];
|
||||||
|
if (sessdata) cookieParts.push(`SESSDATA=${sessdata}`);
|
||||||
|
|
||||||
|
console.log('[danmaku] connecting to', host, 'cookie len:', cookieParts.join('; ').length);
|
||||||
|
// React Native supports non-standard options.headers in the WebSocket constructor
|
||||||
|
const ws = new (WebSocket as any)(host, [], {
|
||||||
|
headers: cookieParts.length ? { Cookie: cookieParts.join('; ') } : {},
|
||||||
|
}) as WebSocket;
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
const authBody = JSON.stringify({
|
const authBody = JSON.stringify({
|
||||||
roomid: roomId,
|
|
||||||
platform: 'web',
|
|
||||||
type: 2,
|
|
||||||
uid: 0,
|
uid: 0,
|
||||||
protover: 0,
|
roomid: roomId,
|
||||||
|
protover: 2,
|
||||||
|
platform: 'h5', // must match platform param used in getConf API call
|
||||||
|
type: 2,
|
||||||
|
key: token,
|
||||||
});
|
});
|
||||||
ws.send(buildPacket(authBody, 7));
|
const authPkt = buildPacket(authBody, 7);
|
||||||
|
const hdr = Array.from(authPkt.slice(0, 16))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||||
|
console.log('[danmaku] auth header hex:', hdr);
|
||||||
|
console.log('[danmaku] auth body len:', authPkt.length - 16);
|
||||||
|
// Send as ArrayBuffer (more reliable than Uint8Array in some RN/Hermes versions)
|
||||||
|
const t0 = Date.now();
|
||||||
|
ws.send(authPkt.buffer as ArrayBuffer);
|
||||||
|
console.log('[danmaku] send done, readyState:', ws.readyState, 'at', t0);
|
||||||
|
|
||||||
heartbeatRef.current = setInterval(() => {
|
heartbeatRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) ws.send(buildPacket('', 2));
|
||||||
ws.send(buildPacket('', 2));
|
|
||||||
}
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (e: MessageEvent) => {
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
try {
|
const bytes = toBytes(e.data);
|
||||||
const packets = parsePackets(e.data as ArrayBuffer);
|
if (!bytes) return;
|
||||||
for (const pkt of packets) {
|
const items = extractDanmaku(bytes);
|
||||||
if (pkt.op === 5 && pkt.body) {
|
if (items.length > 0) setDanmakus(prev => [...prev, ...items]);
|
||||||
try {
|
|
||||||
const msg = JSON.parse(pkt.body);
|
|
||||||
if (msg.cmd === 'DANMU_MSG') {
|
|
||||||
const info = msg.info;
|
|
||||||
const text = info[1] as string;
|
|
||||||
const color = (info[3]?.[3] as number) ?? 0xffffff;
|
|
||||||
const item: DanmakuItem = { time: 0, mode: 1, fontSize: 25, color, text };
|
|
||||||
setDanmakus(prev => [...prev, item]);
|
|
||||||
}
|
|
||||||
} catch { /* ignore single message parse error */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {};
|
ws.onerror = (e: Event) => {
|
||||||
ws.onclose = () => {
|
const ws2 = (e as any).currentTarget as WebSocket;
|
||||||
|
console.warn('[danmaku] ws error, readyState:', ws2?.readyState, 'url:', ws2?.url);
|
||||||
|
};
|
||||||
|
ws.onclose = (e: CloseEvent) => {
|
||||||
|
console.log('[danmaku] ws closed, code:', e.code, 'reason:', e.reason, 'wasClean:', e.wasClean);
|
||||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||||
ws.close();
|
wsRef.current?.close();
|
||||||
};
|
};
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"expo-screen-orientation": "~55.0.8",
|
"expo-screen-orientation": "~55.0.8",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
"expo-system-ui": "~55.0.9",
|
"expo-system-ui": "~55.0.9",
|
||||||
"fast-xml-parser": "^5.5.1",
|
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
"react-native-video": "^6.19.0",
|
"react-native-video": "^6.19.0",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"xml2js": "^0.6.2",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -337,6 +337,18 @@ export async function searchVideos(keyword: string, page = 1): Promise<VideoItem
|
|||||||
} as VideoItem));
|
} as VideoItem));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLiveDanmakuInfo(roomId: number): Promise<{ token: string; host: string }> {
|
||||||
|
const res = await api.get(`${LIVE_BASE}/room/v1/Danmu/getConf`, {
|
||||||
|
params: { room_id: roomId, platform: 'h5' },
|
||||||
|
});
|
||||||
|
const data = res.data.data;
|
||||||
|
const hostInfo = data?.host_server_list?.[0];
|
||||||
|
const host = hostInfo
|
||||||
|
? `wss://${hostInfo.host}:${hostInfo.wss_port}/sub`
|
||||||
|
: 'wss://broadcastlv.chat.bilibili.com/sub';
|
||||||
|
return { token: data?.token ?? '', host };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||||
try {
|
try {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
|
|||||||
@@ -39,22 +39,29 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo
|
|||||||
|
|
||||||
const pairs: (NormalRow | LiveRow)[] = [];
|
const pairs: (NormalRow | LiveRow)[] = [];
|
||||||
for (let i = 0; i < rest.length; i += 2) {
|
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 });
|
pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject 1 LiveRow per chunk at a deterministic position (seed = first video aid)
|
|
||||||
if (liveRooms && roomIdx < liveRooms.length && pairs.length > 0) {
|
|
||||||
const seed = chunk[0]?.aid ?? 0;
|
|
||||||
const insertAt = seed % (pairs.length + 1);
|
|
||||||
pairs.splice(insertAt, 0, {
|
|
||||||
type: 'live',
|
|
||||||
left: liveRooms[roomIdx],
|
|
||||||
});
|
|
||||||
roomIdx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({ type: 'big', item: bigItem });
|
|
||||||
rows.push(...pairs);
|
|
||||||
|
// if (liveRooms && roomIdx < liveRooms.length && pairs.length > 0) {
|
||||||
|
// const seed = chunk[0]?.aid ?? 0;
|
||||||
|
// const insertAt = seed % (pairs.length + 1);
|
||||||
|
// pairs.splice(insertAt, 0, {
|
||||||
|
// type: 'live',
|
||||||
|
// left: liveRooms[roomIdx],
|
||||||
|
// });
|
||||||
|
// roomIdx++;
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
if (rows.length < 20) {
|
||||||
|
rows.push({ type: 'big', item: bigItem }, ...pairs);
|
||||||
|
} else {
|
||||||
|
rows.push(...pairs, { type: 'big', item: bigItem });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user