feat: merge danmaku-fullscreen - resolve NativeVideoPlayer conflict

Combines master's shotTimestamps/pvdata thumbnail improvements and
TouchableWithoutFeedback tap layer with feature/danmaku-fullscreen's
DanmakuItem import, DanmakuOverlay render, showDanmaku state,
onTimeUpdate/initialTime/danmakus/isFullscreen props, danmaku toggle
button, and useWindowDimensions for rotation-aware dimensions.
This commit is contained in:
Developer
2026-03-11 10:07:22 +08:00
11 changed files with 446 additions and 37 deletions

View File

@@ -4,7 +4,7 @@
"slug": "bilibili-app", "slug": "bilibili-app",
"version": "1.0.0", "version": "1.0.0",
"scheme": "bilibili", "scheme": "bilibili",
"orientation": "portrait", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",
"splash": { "splash": {
@@ -37,7 +37,8 @@
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"react-native-video" "react-native-video",
"expo-screen-orientation"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@@ -8,6 +8,9 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { VideoPlayer } from '../../components/VideoPlayer'; import { VideoPlayer } from '../../components/VideoPlayer';
import { CommentItem } from '../../components/CommentItem'; import { CommentItem } from '../../components/CommentItem';
import { getDanmaku } from '../../services/bilibili';
import { DanmakuItem } from '../../services/types';
import DanmakuList from '../../components/DanmakuList';
import { useVideoDetail } from '../../hooks/useVideoDetail'; import { useVideoDetail } from '../../hooks/useVideoDetail';
import { useComments } from '../../hooks/useComments'; import { useComments } from '../../hooks/useComments';
import { useVideoStore } from '../../store/videoStore'; import { useVideoStore } from '../../store/videoStore';
@@ -22,6 +25,9 @@ export default function VideoDetailScreen() {
const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string); const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string);
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0); const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
const [tab, setTab] = useState<Tab>('comments'); const [tab, setTab] = useState<Tab>('comments');
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [showDanmakuList, setShowDanmakuList] = useState(true);
const { setVideo, clearVideo } = useVideoStore(); const { setVideo, clearVideo } = useVideoStore();
useEffect(() => { useEffect(() => {
@@ -32,6 +38,11 @@ export default function VideoDetailScreen() {
if (video?.aid) loadComments(); if (video?.aid) loadComments();
}, [video?.aid]); }, [video?.aid]);
useEffect(() => {
if (!video?.cid) return;
getDanmaku(video.cid).then(setDanmakus);
}, [video?.cid]);
function handleMiniPlayer() { function handleMiniPlayer() {
if (video) { if (video) {
setVideo(bvid as string, video.title, video.pic); setVideo(bvid as string, video.title, video.pic);
@@ -59,6 +70,15 @@ export default function VideoDetailScreen() {
onMiniPlayer={handleMiniPlayer} onMiniPlayer={handleMiniPlayer}
bvid={bvid as string} bvid={bvid as string}
cid={video?.cid} cid={video?.cid}
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
/>
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible={showDanmakuList}
onToggle={() => setShowDanmakuList(v => !v)}
/> />
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}> <ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>

103
components/DanmakuList.tsx Normal file
View File

@@ -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<FlatList>(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 (
<View style={styles.container}>
<TouchableOpacity style={styles.header} onPress={onToggle} activeOpacity={0.7}>
<Ionicons
name={visible ? 'chatbubbles' : 'chatbubbles-outline'}
size={16}
color="#00AEEC"
/>
<Text style={styles.headerText}>
{danmakus.length > 0 ? `(${danmakus.length})` : ''}
</Text>
<Ionicons
name={visible ? 'chevron-up' : 'chevron-down'}
size={14}
color="#999"
/>
</TouchableOpacity>
{visible && (
<FlatList
ref={flatListRef}
data={visibleItems}
keyExtractor={(item, i) => `${item.time}_${item.text}_${i}`}
style={styles.list}
renderItem={({ item }) => (
<Text
style={[styles.item, { color: danmakuColorToCss(item.color) }]}
numberOfLines={1}
>
{item.text}
</Text>
)}
ListEmptyComponent={
<Text style={styles.empty}></Text>
}
/>
)}
</View>
);
}
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,
},
});

View File

@@ -0,0 +1,170 @@
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<ActiveDanmaku[]>([]);
const laneAvailAt = useRef<number[]>(new Array(LANE_COUNT).fill(0));
const activated = useRef<Set<string>>(new Set());
const prevTimeRef = useRef<number>(currentTime);
const idCounter = useRef(0);
const mountedRef = useRef(true);
useEffect(() => {
return () => { mountedRef.current = false; };
}, []);
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(() => {
activated.current.clear();
laneAvailAt.current.fill(0);
setActiveDanmakus([]);
}, [danmakus]);
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[] = [];
if (activated.current.size > 200) activated.current.clear(); // prevent memory leak
for (const item of candidates) {
const key = `${item.time}_${item.text}`;
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(() => {
if (mountedRef.current) 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(() => {
if (mountedRef.current) 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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents="none">
{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 (
<Animated.Text
key={d.id}
style={{
position: 'absolute',
top: isScrolling
? 20 + d.lane * LANE_H
: isTop
? 20
: screenHeight - 48,
left: isScrolling ? 0 : undefined,
alignSelf: !isScrolling ? 'center' : undefined,
transform: isScrolling ? [{ translateX: d.tx }] : [],
opacity: d.opacity,
color: danmakuColorToCss(d.item.color),
fontSize,
fontWeight: '700',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}
>
{d.item.text}
</Animated.Text>
);
})}
</View>
);
}

View File

@@ -1,18 +1,16 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { import {
View, StyleSheet, Dimensions, TouchableOpacity, TouchableWithoutFeedback, View, StyleSheet, TouchableOpacity, TouchableWithoutFeedback,
Text, Modal, Image, PanResponder, Text, Modal, Image, PanResponder, useWindowDimensions,
} from 'react-native'; } from 'react-native';
import Video, { VideoRef } from 'react-native-video'; import Video, { VideoRef } from 'react-native-video';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons'; 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 { buildDashMpdUri } from '../utils/dash';
import { getHeatmap, getVideoShot } from '../services/bilibili'; import { getHeatmap, getVideoShot } from '../services/bilibili';
import DanmakuOverlay from './DanmakuOverlay';
const { width: SCREEN_W } = Dimensions.get('window');
// 16:9 视频区域高度,适配不同屏幕宽度
const VIDEO_H = SCREEN_W * 0.5625;
const BAR_H = 3; const BAR_H = 3;
// 进度球尺寸 // 进度球尺寸
const BALL = 12; const BALL = 12;
@@ -120,12 +118,19 @@ interface Props {
style?: object; style?: object;
bvid?: string; bvid?: string;
cid?: number; cid?: number;
danmakus?: DanmakuItem[];
isFullscreen?: boolean;
onTimeUpdate?: (t: number) => void;
initialTime?: number;
} }
export function NativeVideoPlayer({ export function NativeVideoPlayer({
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style, playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
bvid, cid, bvid, cid, danmakus, isFullscreen, onTimeUpdate, initialTime,
}: Props) { }: Props) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(); const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
const isDash = !!playData?.dash; const isDash = !!playData?.dash;
@@ -143,12 +148,13 @@ export function NativeVideoPlayer({
const isSeekingRef = useRef(false); const isSeekingRef = useRef(false);
const [touchX, setTouchX] = useState<number | null>(null); const [touchX, setTouchX] = useState<number | null>(null);
const barOffsetX = useRef(0); const barOffsetX = useRef(0);
const barWidthRef = useRef(SCREEN_W); const barWidthRef = useRef(300);
const trackRef = useRef<View>(null); const trackRef = useRef<View>(null);
const [heatSegments, setHeatSegments] = useState<number[]>([]); const [heatSegments, setHeatSegments] = useState<number[]>([]);
const [shots, setShots] = useState<VideoShotData | null>(null); const [shots, setShots] = useState<VideoShotData | null>(null);
const [shotTimestamps, setShotTimestamps] = useState<number[]>([]); const [shotTimestamps, setShotTimestamps] = useState<number[]>([]);
const [showDanmaku, setShowDanmaku] = useState(true);
const videoRef = useRef<VideoRef>(null); const videoRef = useRef<VideoRef>(null);
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD'); const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD');
@@ -324,12 +330,28 @@ export function NativeVideoPlayer({
onProgress={({ currentTime: ct, seekableDuration: dur }) => { onProgress={({ currentTime: ct, seekableDuration: dur }) => {
setCurrentTime(ct); setCurrentTime(ct);
if (dur > 0) setDuration(dur); if (dur > 0) setDuration(dur);
onTimeUpdate?.(ct);
}}
onLoad={() => {
if (initialTime && initialTime > 0) {
videoRef.current?.seek(initialTime);
}
}} }}
/> />
) : ( ) : (
<View style={styles.placeholder} /> <View style={styles.placeholder} />
)} )}
{isFullscreen && !!danmakus?.length && (
<DanmakuOverlay
danmakus={danmakus}
currentTime={currentTime}
screenWidth={SCREEN_W}
screenHeight={SCREEN_H}
visible={showDanmaku}
/>
)}
{/* Permanent transparent tap layer — always above Video so taps always reach it */} {/* 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} />
@@ -400,6 +422,11 @@ export function NativeVideoPlayer({
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}> <TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
<Text style={styles.qualityText}>{currentDesc}</Text> <Text style={styles.qualityText}>{currentDesc}</Text>
</TouchableOpacity> </TouchableOpacity>
{isFullscreen && (
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowDanmaku(v => !v)}>
<Ionicons name={showDanmaku ? 'chatbubbles' : 'chatbubbles-outline'} size={16} color="#fff" />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}> <TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
<Ionicons name="expand" size={16} color="#fff" /> <Ionicons name="expand" size={16} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
@@ -436,7 +463,7 @@ export function NativeVideoPlayer({
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { width: SCREEN_W, height: VIDEO_H, backgroundColor: '#000' }, container: { backgroundColor: '#000' },
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' }, placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
topBar: { topBar: {
position: 'absolute', top: 0, left: 0, right: 0, height: 56, position: 'absolute', top: 0, left: 0, right: 0, height: 56,

View File

@@ -1,10 +1,8 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { View, StyleSheet, Dimensions, Text, Platform, Modal, StatusBar } from 'react-native'; import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native';
import * as ScreenOrientation from 'expo-screen-orientation';
import { NativeVideoPlayer } from './NativeVideoPlayer'; import { NativeVideoPlayer } from './NativeVideoPlayer';
import type { PlayUrlResponse } from '../services/types'; import type { PlayUrlResponse, DanmakuItem } from '../services/types';
const { width } = Dimensions.get('window');
const VIDEO_HEIGHT = width * 0.5625;
interface Props { interface Props {
playData: PlayUrlResponse | null; playData: PlayUrlResponse | null;
@@ -14,14 +12,38 @@ interface Props {
onMiniPlayer?: () => void; onMiniPlayer?: () => void;
bvid?: string; bvid?: string;
cid?: number; cid?: number;
danmakus?: DanmakuItem[];
onTimeUpdate?: (t: number) => void;
} }
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, bvid, cid }: Props) { export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, bvid, cid, danmakus, onTimeUpdate }: Props) {
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const { width } = useWindowDimensions();
const VIDEO_HEIGHT = width * 0.5625;
const lastTimeRef = useRef(0);
const handleEnterFullscreen = async () => {
setFullscreen(true);
if (Platform.OS !== 'web')
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
};
const handleExitFullscreen = async () => {
setFullscreen(false);
if (Platform.OS !== 'web')
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
};
useEffect(() => {
return () => {
if (Platform.OS !== 'web')
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
};
}, []);
if (!playData) { if (!playData) {
return ( return (
<View style={[styles.container, styles.placeholder]}> <View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}>
<Text style={styles.placeholderText}>...</Text> <Text style={styles.placeholderText}>...</Text>
</View> </View>
); );
@@ -30,7 +52,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
const url = playData.durl?.[0]?.url ?? ''; const url = playData.durl?.[0]?.url ?? '';
return ( return (
<View style={styles.container}> <View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
<video <video
src={url} src={url}
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any} style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
@@ -43,29 +65,37 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
return ( return (
<> <>
<NativeVideoPlayer {/* Portrait player: unmount when fullscreen */}
playData={playData} {!fullscreen && (
qualities={qualities} <NativeVideoPlayer
currentQn={currentQn} playData={playData}
onQualityChange={onQualityChange} qualities={qualities}
onFullscreen={() => setFullscreen(true)} currentQn={currentQn}
onMiniPlayer={onMiniPlayer} onQualityChange={onQualityChange}
bvid={bvid} onFullscreen={handleEnterFullscreen}
cid={cid} onMiniPlayer={onMiniPlayer}
/> bvid={bvid}
cid={cid}
isFullscreen={false}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
/>
)}
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent> <Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
<StatusBar hidden /> <StatusBar hidden />
<View style={styles.fullscreenContainer}> <View style={{ flex: 1, backgroundColor: '#000' }}>
<NativeVideoPlayer <NativeVideoPlayer
playData={playData} playData={playData}
qualities={qualities} qualities={qualities}
currentQn={currentQn} currentQn={currentQn}
onQualityChange={onQualityChange} onQualityChange={onQualityChange}
onFullscreen={() => setFullscreen(false)} onFullscreen={handleExitFullscreen}
bvid={bvid} bvid={bvid}
cid={cid} cid={cid}
style={{ width: '100%', height: '100%' } as any} danmakus={danmakus}
isFullscreen={true}
initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
/> />
</View> </View>
</Modal> </Modal>
@@ -74,8 +104,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
placeholder: { justifyContent: 'center', alignItems: 'center' }, placeholder: { justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#fff', fontSize: 14 }, placeholderText: { color: '#fff', fontSize: 14 },
fullscreenContainer: { flex: 1, backgroundColor: '#000' },
}); });

11
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"expo-file-system": "~55.0.10", "expo-file-system": "~55.0.10",
"expo-linear-gradient": "~55.0.8", "expo-linear-gradient": "~55.0.8",
"expo-router": "~55.0.4", "expo-router": "~55.0.4",
"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",
"react": "19.2.0", "react": "19.2.0",
@@ -4668,6 +4669,16 @@
"node": ">=10" "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": { "node_modules/expo-server": {
"version": "55.0.6", "version": "55.0.6",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz",

View File

@@ -19,6 +19,7 @@
"expo-file-system": "~55.0.10", "expo-file-system": "~55.0.10",
"expo-linear-gradient": "~55.0.8", "expo-linear-gradient": "~55.0.8",
"expo-router": "~55.0.4", "expo-router": "~55.0.4",
"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",
"react": "19.2.0", "react": "19.2.0",

View File

@@ -1,12 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native'; 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 { signWbi } from '../utils/wbi';
import { parseDanmakuXml } from '../utils/danmaku';
const isWeb = Platform.OS === 'web'; 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 BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com';
const PASSPORT = isWeb ? 'http://localhost:3001/bilibili-passport' : 'https://passport.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 { function generateBuvid3(): string {
const h = () => Math.floor(Math.random() * 16).toString(16); const h = () => Math.floor(Math.random() * 16).toString(16);
@@ -30,7 +35,7 @@ const api = axios.create({
'Accept': 'application/json, text/plain, */*', 'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9', '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', 'Referer': 'https://www.bilibili.com',
'Origin': 'https://www.bilibili.com', 'Origin': 'https://www.bilibili.com',
'Accept': 'application/json, text/plain, */*', 'Accept': 'application/json, text/plain, */*',
@@ -166,3 +171,14 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
} }
return { code, cookie }; return { code, cookie };
} }
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
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 []; }
}

View File

@@ -95,3 +95,11 @@ export interface HeatmapResponse {
timestamp: number; timestamp: number;
pb_data: string; pb_data: string;
} }
export interface DanmakuItem {
time: number; // 秒float弹幕出现时间
mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定
fontSize: number;
color: number; // 0xRRGGBB 十进制整数
text: string;
}

24
utils/danmaku.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { DanmakuItem } from '../services/types';
export function parseDanmakuXml(xml: string): DanmakuItem[] {
const re = /<d p="([^"]+)">([^<]*)<\/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(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/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');
}