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; }