diff --git a/app/index.tsx b/app/index.tsx
index 3d7db20..ce18aa5 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -432,7 +432,9 @@ export default function HomeScreen() {
搜索视频、UP主...
- 哔
+
+
+
@@ -506,6 +508,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
gap: 5,
},
+ downloadBtn: {},
searchPlaceholder: {
fontSize: 13,
color: "#999",
diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx
index d9bc498..b5df9a1 100644
--- a/app/live/[roomId].tsx
+++ b/app/live/[roomId].tsx
@@ -30,8 +30,9 @@ export default function LiveDetailScreen() {
const qualities = stream?.qualities ?? [];
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 (
{/* TopBar */}
diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx
index bf62979..8aee03a 100644
--- a/app/video/[bvid].tsx
+++ b/app/video/[bvid].tsx
@@ -99,21 +99,38 @@ export default function VideoDetailScreen() {
{/* TabBar — sits directly below player, always visible once video loads */}
{video && (
- setTab("intro")}>
-
+ setTab("intro")}
+ >
+
简介
{tab === "intro" && }
- setTab("comments")}>
-
- 评论{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
+ setTab("comments")}
+ >
+
+ 评论
+ {video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
{tab === "comments" && }
- setTab("danmaku")}>
-
- 弹幕{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
+ setTab("danmaku")}
+ >
+
+ 弹幕
+ {danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
{tab === "danmaku" && }
@@ -126,16 +143,10 @@ export default function VideoDetailScreen() {
) : video ? (
tab === "intro" ? (
// 简介:视频信息 + 合集 + 简介文本
-
-
- {video.title}
-
-
-
-
-
-
-
+
+ 关注
+
+ {video.title}
+
+
+
+
+
+
+
{video.ugc_season && (
String(c.rpid)}
renderItem={({ item }) => }
- onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
+ onEndReached={() => {
+ if (cmtHasMore && !cmtLoading) loadComments();
+ }}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
排序
setCommentSort(2)}
>
- 热门
+
+ 热门
+
setCommentSort(0)}
>
- 最新
+
+ 最新
+
}
@@ -312,9 +354,13 @@ const styles = StyleSheet.create({
},
miniBtn: { padding: 4 },
loader: { marginVertical: 30 },
- titleSection: { padding: 14 },
+ titleSection: {
+ padding: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: "#f0f0f0",
+ },
title: {
- fontSize: 16,
+ fontSize: 13,
fontWeight: "600",
color: "#212121",
lineHeight: 22,
@@ -326,20 +372,19 @@ const styles = StyleSheet.create({
upRow: {
flexDirection: "row",
alignItems: "center",
- paddingHorizontal: 14,
- paddingBottom: 12,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: "#f0f0f0",
+ paddingHorizontal: 10,
+ paddingBottom: 0,
+ paddingTop: 12,
},
- 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" },
followBtn: {
backgroundColor: "#00AEEC",
- paddingHorizontal: 14,
- paddingVertical: 5,
+ paddingHorizontal: 10,
+ paddingVertical: 3,
borderRadius: 14,
},
- followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
+ followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" },
seasonBox: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#f0f0f0",
@@ -384,8 +429,8 @@ const styles = StyleSheet.create({
paddingVertical: 12,
position: "relative",
},
- tabLabel: { fontSize: 14, color: "#999" },
- tabActive: { color: "#00AEEC", fontWeight: "700" },
+ tabLabel: { fontSize: 13, color: "#999" },
+ tabActive: { color: "#00AEEC" },
tabUnderline: {
position: "absolute",
bottom: 0,
diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx
index 764aaaf..9d7d716 100644
--- a/components/NativeVideoPlayer.tsx
+++ b/components/NativeVideoPlayer.tsx
@@ -39,10 +39,10 @@ const HEADERS = {
function clamp(v: number, lo: number, hi: number) {
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 {
- let lo = 0, hi = index.length - 1;
+ let lo = 0,
+ hi = index.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (index[mid] <= seekTime) lo = mid;
@@ -51,7 +51,6 @@ function findFrameByTime(index: number[], seekTime: number): number {
return lo;
}
-
interface Props {
playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[];
@@ -117,7 +116,7 @@ export function NativeVideoPlayer({
qualities.find((q) => q.qn === currentQn)?.desc ??
String(currentQn || "HD");
- // URL resolution
+ // 解析播放链接,dash 需要构建 mpd uri,普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
useEffect(() => {
if (!playData) {
setResolvedUrl(undefined);
@@ -131,8 +130,7 @@ export function NativeVideoPlayer({
setResolvedUrl(playData.durl?.[0]?.url);
}
}, [playData, currentQn]);
-
- // Video shots (thumbnails for seek preview)
+ // 获取视频截图数据,供进度条预览使用。依赖 bvid 和 cid,确保在视频切换时重新获取截图。使用 cancelled 标志避免在组件卸载后更新状态,防止内存泄漏和潜在的错误。
useEffect(() => {
if (!bvid || !cid) return;
let cancelled = false;
@@ -140,7 +138,6 @@ export function NativeVideoPlayer({
if (cancelled) return;
if (shotData?.image?.length) {
setShots(shotData);
- console.log(shotData.index,shotData.image,'缩略图长度')
}
});
return () => {
@@ -152,18 +149,20 @@ export function NativeVideoPlayer({
durationRef.current = duration;
}, [duration]);
+ // 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
if (!isSeekingRef.current) {
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
}
}, []);
-
+ // 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
const showAndReset = useCallback(() => {
setShowControls(true);
resetHideTimer();
}, [resetHideTimer]);
+ // 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
const handleTap = useCallback(() => {
setShowControls((prev) => {
if (!prev) {
@@ -175,7 +174,7 @@ export function NativeVideoPlayer({
});
}, [resetHideTimer]);
- // Start hide timer on mount
+ // 组件卸载时清理隐藏计时器,避免内存泄漏和潜在的状态更新错误。依赖项为空数组确保只在挂载和卸载时执行一次。
useEffect(() => {
resetHideTimer();
return () => {
@@ -191,7 +190,7 @@ export function NativeVideoPlayer({
}
});
}, []);
-
+ // 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
@@ -218,6 +217,7 @@ export function NativeVideoPlayer({
});
}
},
+ // 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
onPanResponderRelease: (_, gs) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
@@ -253,7 +253,7 @@ export function NativeVideoPlayer({
},
}),
).current;
-
+ // 进度条上触摸位置对应的时间点比例,0-1。非拖动状态为 null
const touchRatio =
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
@@ -274,32 +274,37 @@ export function NativeVideoPlayer({
const framesPerSheet = img_x_len * img_y_len;
const totalFrames = framesPerSheet * image.length;
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 = index?.length && duration > 0
- ? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
- : clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1);
+ // 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
+ const frameIdx =
+ index?.length && duration > 0
+ ? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
+ : clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1);
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
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 });
-
- // Scale sprite frame to display size
+ console.log("[thumb]", {
+ seekTime,
+ duration,
+ indexLen: index?.length,
+ frameIdx,
+ totalFrames,
+ sheetIdx,
+ col,
+ row,
+ });
+ // 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
const scale = THUMB_DISPLAY_W / TW;
const DW = THUMB_DISPLAY_W;
const DH = Math.round(TH * scale);
const trackLeft = barOffsetX.current;
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
-
- // Protocol-relative URLs from B站 API need explicit https:
+ // 兼容处理图床地址,确保以 http(s) 协议开头
const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}`
: image[sheetIdx];
- console.log(sheetUrl,'sheetUrlsheetUrl')
return (
)}
- {/* Permanent transparent tap layer — always above Video so taps always reach it */}
@@ -401,7 +405,6 @@ export function NativeVideoPlayer({
)}
- {/* Center play/pause */}
{
@@ -418,13 +421,11 @@ export function NativeVideoPlayer({
- {/* Bottom bar */}
- {/* Progress track */}
- {/* Buffered: lighter white overlay on top of base */}
- {/* Played: accent color on top */}
)}
+ {/* Controls */}
- {/* Controls row */}
{
@@ -517,10 +516,8 @@ export function NativeVideoPlayer({
>
)}
- {/* Thumbnail preview — absolute on container to avoid clipping */}
{renderThumbnail()}
-
- {/* Quality modal */}
+ {/* 选清晰度 */}
>> 0;
+}
+function r16(b: Uint8Array, o: number): number {
+ return ((b[o] << 8) | b[o + 1]) >>> 0;
}
-function parsePackets(buf: ArrayBuffer): { op: number; body: string }[] {
- const packets: { op: number; body: string }[] = [];
+// Build packet using raw bit ops – avoids DataView / TextEncoder quirks in Hermes.
+// 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;
- const view = new DataView(buf);
- while (offset + 16 <= buf.byteLength) {
- const totalLen = view.getUint32(offset, false);
- const headerLen = view.getUint16(offset + 4, false);
- const op = view.getUint32(offset + 8, false);
- if (totalLen < headerLen || offset + totalLen > buf.byteLength) break;
- const bodyLen = totalLen - headerLen;
- const bodyBytes = new Uint8Array(buf, offset + headerLen, bodyLen);
- const body = new TextDecoder('utf-8').decode(bodyBytes);
- packets.push({ op, body });
+ while (offset + 16 <= buf.length) {
+ const totalLen = r32(buf, offset);
+ const headerLen = r16(buf, offset + 4);
+ const ver = r16(buf, offset + 6);
+ const op = r32(buf, offset + 8);
+ if (totalLen < headerLen || offset + totalLen > buf.length) break;
+ packets.push({ op, ver, body: buf.slice(offset + headerLen, offset + totalLen) });
offset += totalLen;
}
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[] {
const [danmakus, setDanmakus] = useState([]);
const heartbeatRef = useRef | null>(null);
+ const wsRef = useRef(null);
useEffect(() => {
if (!roomId) return;
setDanmakus([]);
+ let cancelled = false;
- const ws = new WebSocket('wss://broadcastlv.chat.bilibili.com/sub');
- ws.binaryType = 'arraybuffer';
-
- ws.onopen = () => {
- const authBody = JSON.stringify({
- roomid: roomId,
- platform: 'web',
- type: 2,
- uid: 0,
- protover: 0,
- });
- ws.send(buildPacket(authBody, 7));
-
- heartbeatRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(buildPacket('', 2));
- }
- }, 30000);
- };
-
- ws.onmessage = (e: MessageEvent) => {
+ async function connect() {
+ let token = '';
+ let host = 'wss://broadcastlv.chat.bilibili.com/sub';
try {
- const packets = parsePackets(e.data as ArrayBuffer);
- for (const pkt of packets) {
- if (pkt.op === 5 && pkt.body) {
- 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 */ }
- };
+ 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;
- ws.onerror = () => {};
- ws.onclose = () => {
- if (heartbeatRef.current) clearInterval(heartbeatRef.current);
- };
+ // 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 = () => {
+ const authBody = JSON.stringify({
+ uid: 0,
+ roomid: roomId,
+ protover: 2,
+ platform: 'h5', // must match platform param used in getConf API call
+ type: 2,
+ key: token,
+ });
+ 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(() => {
+ if (ws.readyState === WebSocket.OPEN) ws.send(buildPacket('', 2));
+ }, 30000);
+ };
+
+ ws.onmessage = (e: MessageEvent) => {
+ const bytes = toBytes(e.data);
+ if (!bytes) return;
+ const items = extractDanmaku(bytes);
+ if (items.length > 0) setDanmakus(prev => [...prev, ...items]);
+ };
+
+ ws.onerror = (e: Event) => {
+ 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);
+ };
+ }
+
+ connect();
return () => {
+ cancelled = true;
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
- ws.close();
+ wsRef.current?.close();
};
}, [roomId]);
diff --git a/package.json b/package.json
index 543357e..157b5d9 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,6 @@
"expo-screen-orientation": "~55.0.8",
"expo-status-bar": "~55.0.4",
"expo-system-ui": "~55.0.9",
- "fast-xml-parser": "^5.5.1",
"pako": "^2.1.0",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -33,7 +32,6 @@
"react-native-video": "^6.19.0",
"react-native-web": "^0.21.0",
"react-native-webview": "13.16.0",
- "xml2js": "^0.6.2",
"zustand": "^5.0.11"
},
"devDependencies": {
diff --git a/services/bilibili.ts b/services/bilibili.ts
index 8ad5e9b..73a8dcf 100644
--- a/services/bilibili.ts
+++ b/services/bilibili.ts
@@ -337,6 +337,18 @@ export async function searchVideos(keyword: string, page = 1): Promise {
+ 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 {
try {
if (isWeb) {
@@ -354,7 +366,7 @@ export async function getDanmaku(cid: number): Promise {
responseType: 'arraybuffer',
});
- const bytes = new Uint8Array(res.data as ArrayBuffer);
+ const bytes = new Uint8Array(res.data as ArrayBuffer);
let xmlText: string | undefined;
// 依次尝试:inflate (gzip/zlib) → inflateRaw (raw deflate)
diff --git a/utils/videoRows.ts b/utils/videoRows.ts
index 886ca08..6f75524 100644
--- a/utils/videoRows.ts
+++ b/utils/videoRows.ts
@@ -39,22 +39,29 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo
const pairs: (NormalRow | LiveRow)[] = [];
for (let i = 0; i < rest.length; i += 2) {
- pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
+ if (rest[i + 1]) {
+ 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;
}