From e5568b6b6bcca0eec326a2a3e723071f42a96a2c Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:42:29 +0800 Subject: [PATCH 01/10] chore: install expo-screen-orientation, set orientation to default --- app.json | 5 +++-- package-lock.json | 11 +++++++++++ package.json | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app.json b/app.json index 2683ddd..776489a 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "bilibili-app", "version": "1.0.0", "scheme": "bilibili", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "light", "splash": { @@ -37,7 +37,8 @@ }, "plugins": [ "expo-router", - "react-native-video" + "react-native-video", + "expo-screen-orientation" ], "experiments": { "typedRoutes": true diff --git a/package-lock.json b/package-lock.json index 95549c0..a949a68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "expo-file-system": "~55.0.10", "expo-linear-gradient": "~55.0.8", "expo-router": "~55.0.4", + "expo-screen-orientation": "~55.0.8", "expo-status-bar": "~55.0.4", "expo-system-ui": "~55.0.9", "react": "19.2.0", @@ -4668,6 +4669,16 @@ "node": ">=10" } }, + "node_modules/expo-screen-orientation": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-55.0.8.tgz", + "integrity": "sha512-KhsOX17s/2c8Lv4QKOYsfkPEwpZTGP/V4lZQ5dUMRXAtueMNPg9ewcb4KrJrpvqO3j+yRYIU8t95WRCq1zjmDw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "55.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz", diff --git a/package.json b/package.json index 16b42e0..ab25ff2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "expo-file-system": "~55.0.10", "expo-linear-gradient": "~55.0.8", "expo-router": "~55.0.4", + "expo-screen-orientation": "~55.0.8", "expo-status-bar": "~55.0.4", "expo-system-ui": "~55.0.9", "react": "19.2.0", From 8572f30e16c67ba9d70bca1b019d1bd6ec2fcf4b Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:43:16 +0800 Subject: [PATCH 02/10] feat: add DanmakuItem type --- services/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/types.ts b/services/types.ts index 660c1e3..db75e6b 100644 --- a/services/types.ts +++ b/services/types.ts @@ -94,3 +94,11 @@ export interface HeatmapResponse { timestamp: number; pb_data: string; } + +export interface DanmakuItem { + time: number; // 秒(float),弹幕出现时间 + mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定 + fontSize: number; + color: number; // 0xRRGGBB 十进制整数 + text: string; +} From bfeae44bf9abb741634bc6d82c13ac5ee62cc731 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:44:24 +0800 Subject: [PATCH 03/10] feat: add danmaku XML parser and color util --- utils/danmaku.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 utils/danmaku.ts diff --git a/utils/danmaku.ts b/utils/danmaku.ts new file mode 100644 index 0000000..f61f847 --- /dev/null +++ b/utils/danmaku.ts @@ -0,0 +1,24 @@ +import type { DanmakuItem } from '../services/types'; + +export function parseDanmakuXml(xml: string): DanmakuItem[] { + const re = /([^<]*)<\/d>/g; + const items: DanmakuItem[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) { + const p = m[1].split(','); + if (p.length < 4) continue; + const time = parseFloat(p[0]); + const mode = parseInt(p[1], 10); + const text = m[2] + .replace(/&/g, '&').replace(/</g, '<') + .replace(/>/g, '>').replace(/"/g, '"').trim(); + if (!text || isNaN(time)) continue; + if (mode !== 1 && mode !== 4 && mode !== 5) continue; + items.push({ time, mode: mode as 1|4|5, fontSize: parseInt(p[2],10), color: parseInt(p[3],10), text }); + } + return items.sort((a, b) => a.time - b.time); +} + +export function danmakuColorToCss(color: number): string { + return '#' + (color >>> 0 & 0xFFFFFF).toString(16).padStart(6, '0'); +} From d2e259e9f4f7225d4f754650cc0ca9b5579373ed Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:46:29 +0800 Subject: [PATCH 04/10] feat: add getDanmaku API function --- services/bilibili.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/services/bilibili.ts b/services/bilibili.ts index 7922d1f..578c934 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -1,12 +1,17 @@ import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; -import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, HeatmapResponse } from './types'; +import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, HeatmapResponse, DanmakuItem } from './types'; import { signWbi } from '../utils/wbi'; +import { parseDanmakuXml } from '../utils/danmaku'; const isWeb = Platform.OS === 'web'; +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com'; const PASSPORT = isWeb ? 'http://localhost:3001/bilibili-passport' : 'https://passport.bilibili.com'; +const COMMENT_BASE = isWeb + ? 'http://localhost:3001/bilibili-comment' + : 'https://comment.bilibili.com'; function generateBuvid3(): string { const h = () => Math.floor(Math.random() * 16).toString(16); @@ -30,7 +35,7 @@ const api = axios.create({ 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9', } : { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'User-Agent': UA, 'Referer': 'https://www.bilibili.com', 'Origin': 'https://www.bilibili.com', 'Accept': 'application/json, text/plain, */*', @@ -166,3 +171,14 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co } return { code, cookie }; } + +export async function getDanmaku(cid: number): Promise { + try { + const res = await axios.get(`${COMMENT_BASE}/x/v1/dm/list.so`, { + params: { oid: cid }, + headers: isWeb ? {} : { Referer: 'https://www.bilibili.com', 'User-Agent': UA }, + responseType: 'text', + }); + return parseDanmakuXml(res.data as string); + } catch { return []; } +} From 44b49101356d0e0c9dc04bdbf8df4922254d0a83 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:47:11 +0800 Subject: [PATCH 05/10] feat: add DanmakuList component --- components/DanmakuList.tsx | 103 +++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 components/DanmakuList.tsx diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx new file mode 100644 index 0000000..f92436c --- /dev/null +++ b/components/DanmakuList.tsx @@ -0,0 +1,103 @@ +import React, { useRef, useMemo, useEffect } from 'react'; +import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { DanmakuItem } from '../services/types'; +import { danmakuColorToCss } from '../utils/danmaku'; + +interface Props { + danmakus: DanmakuItem[]; + currentTime: number; + visible: boolean; + onToggle: () => void; +} + +export default function DanmakuList({ danmakus, currentTime, visible, onToggle }: Props) { + const flatListRef = useRef(null); + + const visibleItems = useMemo( + () => danmakus.filter(d => d.time <= currentTime), + [danmakus, currentTime] + ); + + useEffect(() => { + if (visible && visibleItems.length > 0) { + flatListRef.current?.scrollToEnd({ animated: true }); + } + }, [visibleItems.length, visible]); + + return ( + + + + + 弹幕 {danmakus.length > 0 ? `(${danmakus.length})` : ''} + + + + + {visible && ( + String(i)} + style={styles.list} + renderItem={({ item }) => ( + + {item.text} + + )} + ListEmptyComponent={ + 暂无弹幕 + } + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#fff', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#eee', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 6, + }, + headerText: { + flex: 1, + fontSize: 13, + color: '#212121', + fontWeight: '500', + }, + list: { + height: 180, + paddingHorizontal: 12, + }, + item: { + fontSize: 13, + paddingVertical: 3, + lineHeight: 18, + }, + empty: { + fontSize: 12, + color: '#999', + textAlign: 'center', + paddingVertical: 20, + }, +}); From d1b66541d2ed4ef3cd25c97156512037d137f98b Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:48:09 +0800 Subject: [PATCH 06/10] feat: add DanmakuOverlay component --- components/DanmakuOverlay.tsx | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 components/DanmakuOverlay.tsx diff --git a/components/DanmakuOverlay.tsx b/components/DanmakuOverlay.tsx new file mode 100644 index 0000000..d6e9d86 --- /dev/null +++ b/components/DanmakuOverlay.tsx @@ -0,0 +1,160 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { View, Animated, StyleSheet, Text } from 'react-native'; +import { DanmakuItem } from '../services/types'; +import { danmakuColorToCss } from '../utils/danmaku'; + +interface Props { + danmakus: DanmakuItem[]; + currentTime: number; + screenWidth: number; + screenHeight: number; + visible: boolean; +} + +const LANE_COUNT = 5; +const LANE_H = 28; + +interface ActiveDanmaku { + id: string; + item: DanmakuItem; + lane: number; + tx: Animated.Value; + opacity: Animated.Value; +} + +export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, screenHeight, visible }: Props) { + const [activeDanmakus, setActiveDanmakus] = useState([]); + const laneAvailAt = useRef(new Array(LANE_COUNT).fill(0)); + const activated = useRef>(new Set()); + const prevTimeRef = useRef(currentTime); + const idCounter = useRef(0); + + const pickLane = useCallback((): number | null => { + const now = Date.now(); + for (let i = 0; i < LANE_COUNT; i++) { + if (laneAvailAt.current[i] <= now) return i; + } + return null; + }, []); + + useEffect(() => { + if (!visible) return; + + const prevTime = prevTimeRef.current; + const didSeek = Math.abs(currentTime - prevTime) > 2; + prevTimeRef.current = currentTime; + + if (didSeek) { + // Clear on seek + activated.current.clear(); + laneAvailAt.current.fill(0); + setActiveDanmakus([]); + return; + } + + // Find danmakus in the activation window + const window = 0.4; + const candidates = danmakus.filter(d => { + const key = `${d.time}_${d.text}`; + return d.time >= currentTime - window && d.time <= currentTime + window && !activated.current.has(key); + }); + + if (candidates.length === 0) return; + + const newItems: ActiveDanmaku[] = []; + + for (const item of candidates) { + const key = `${item.time}_${item.text}`; + if (activated.current.size > 200) activated.current.clear(); // prevent memory leak + activated.current.add(key); + + if (item.mode === 1) { + // Scrolling danmaku + const lane = pickLane(); + if (lane === null) continue; // drop if all lanes full + + const charWidth = Math.min(item.fontSize, 22) * 0.8; + const textWidth = item.text.length * charWidth; + const duration = 8000; + // Lane becomes available when tail of this danmaku clears the right edge of screen + // tail starts at screenWidth, text has width textWidth + // tail clears left edge at duration ms + // lane available when head of next can start without overlapping: when tail clears screen right = immediately for next (head starts at screenWidth) + // conservative: lane free after textWidth / (2*screenWidth) * duration ms + const laneDelay = (textWidth / (screenWidth + textWidth)) * duration; + laneAvailAt.current[lane] = Date.now() + laneDelay; + + const tx = new Animated.Value(screenWidth); + const id = `d_${idCounter.current++}`; + + newItems.push({ id, item, lane, tx, opacity: new Animated.Value(1) }); + + Animated.timing(tx, { + toValue: -textWidth - 20, + duration, + useNativeDriver: true, + }).start(() => { + setActiveDanmakus(prev => prev.filter(d => d.id !== id)); + }); + } else { + // Fixed danmaku (mode 4 = bottom, mode 5 = top) + const opacity = new Animated.Value(1); + const id = `d_${idCounter.current++}`; + newItems.push({ id, item, lane: -1, tx: new Animated.Value(0), opacity }); + + Animated.sequence([ + Animated.delay(2000), + Animated.timing(opacity, { toValue: 0, duration: 500, useNativeDriver: true }), + ]).start(() => { + setActiveDanmakus(prev => prev.filter(d => d.id !== id)); + }); + } + } + + if (newItems.length > 0) { + setActiveDanmakus(prev => { + const combined = [...prev, ...newItems]; + // Cap at 30 simultaneous danmakus + return combined.slice(Math.max(0, combined.length - 30)); + }); + } + }, [currentTime, visible, danmakus, pickLane, screenWidth]); + + if (!visible) return null; + + return ( + + {activeDanmakus.map(d => { + const fontSize = Math.min(d.item.fontSize || 25, 22); + const isScrolling = d.item.mode === 1; + const isTop = d.item.mode === 5; + + return ( + + {d.item.text} + + ); + })} + + ); +} From 85ceec8b58d2ac324c2443d8b44870984807e859 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 22:50:48 +0800 Subject: [PATCH 07/10] feat: add danmaku overlay + time tracking to NativeVideoPlayer --- components/NativeVideoPlayer.tsx | 46 +++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 6b1e17d..9ad15d6 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -1,17 +1,16 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { - View, StyleSheet, Dimensions, TouchableOpacity, TouchableWithoutFeedback, - Text, Modal, Image, PanResponder, + View, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, + Text, Modal, Image, PanResponder, useWindowDimensions, } from 'react-native'; import Video, { VideoRef } from 'react-native-video'; import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; -import type { PlayUrlResponse, VideoShotData } from '../services/types'; +import type { PlayUrlResponse, VideoShotData, DanmakuItem } from '../services/types'; import { buildDashMpdUri } from '../utils/dash'; import { getHeatmap, getVideoShot } from '../services/bilibili'; +import { DanmakuOverlay } from './DanmakuOverlay'; -const { width: SCREEN_W } = Dimensions.get('window'); -const VIDEO_H = SCREEN_W * 0.5625; const BAR_H = 3; const BALL = 12; const BALL_ACTIVE = 16; @@ -80,12 +79,19 @@ interface Props { style?: object; bvid?: string; cid?: number; + danmakus?: DanmakuItem[]; + isFullscreen?: boolean; + onTimeUpdate?: (t: number) => void; + initialTime?: number; } export function NativeVideoPlayer({ playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style, - bvid, cid, + bvid, cid, danmakus, isFullscreen, onTimeUpdate, initialTime, }: Props) { + const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions(); + const VIDEO_H = SCREEN_W * 0.5625; + const [resolvedUrl, setResolvedUrl] = useState(); const isDash = !!playData?.dash; @@ -103,11 +109,12 @@ export function NativeVideoPlayer({ const isSeekingRef = useRef(false); const [touchX, setTouchX] = useState(null); const barOffsetX = useRef(0); - const barWidthRef = useRef(SCREEN_W); + const barWidthRef = useRef(300); const trackRef = useRef(null); const [heatSegments, setHeatSegments] = useState([]); const [shots, setShots] = useState(null); + const [showDanmaku, setShowDanmaku] = useState(true); const videoRef = useRef(null); const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD'); @@ -244,7 +251,7 @@ export function NativeVideoPlayer({ return ( - + {resolvedUrl ? (