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/app/video/[bvid].tsx b/app/video/[bvid].tsx index dff70fe..ad36dbd 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -8,6 +8,9 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { VideoPlayer } from '../../components/VideoPlayer'; 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 { useComments } from '../../hooks/useComments'; 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 { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0); const [tab, setTab] = useState('comments'); + const [danmakus, setDanmakus] = useState([]); + const [currentTime, setCurrentTime] = useState(0); + const [showDanmakuList, setShowDanmakuList] = useState(true); const { setVideo, clearVideo } = useVideoStore(); useEffect(() => { @@ -32,6 +38,11 @@ export default function VideoDetailScreen() { if (video?.aid) loadComments(); }, [video?.aid]); + useEffect(() => { + if (!video?.cid) return; + getDanmaku(video.cid).then(setDanmakus); + }, [video?.cid]); + function handleMiniPlayer() { if (video) { setVideo(bvid as string, video.title, video.pic); @@ -59,6 +70,15 @@ export default function VideoDetailScreen() { onMiniPlayer={handleMiniPlayer} bvid={bvid as string} cid={video?.cid} + danmakus={danmakus} + onTimeUpdate={setCurrentTime} + /> + + setShowDanmakuList(v => !v)} /> diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx new file mode 100644 index 0000000..df55433 --- /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 && ( + `${item.time}_${item.text}_${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, + }, +}); diff --git a/components/DanmakuOverlay.tsx b/components/DanmakuOverlay.tsx new file mode 100644 index 0000000..a36f875 --- /dev/null +++ b/components/DanmakuOverlay.tsx @@ -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([]); + const laneAvailAt = useRef(new Array(LANE_COUNT).fill(0)); + const activated = useRef>(new Set()); + const prevTimeRef = useRef(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 ( + + {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} + + ); + })} + + ); +} diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 17f3e7e..ab96f80 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -1,18 +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'); -// 16:9 视频区域高度,适配不同屏幕宽度 -const VIDEO_H = SCREEN_W * 0.5625; const BAR_H = 3; // 进度球尺寸 const BALL = 12; @@ -120,12 +118,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; @@ -143,12 +148,13 @@ 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 [shotTimestamps, setShotTimestamps] = useState([]); + const [showDanmaku, setShowDanmaku] = useState(true); const videoRef = useRef(null); const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD'); @@ -324,12 +330,28 @@ export function NativeVideoPlayer({ onProgress={({ currentTime: ct, seekableDuration: dur }) => { setCurrentTime(ct); if (dur > 0) setDuration(dur); + onTimeUpdate?.(ct); + }} + onLoad={() => { + if (initialTime && initialTime > 0) { + videoRef.current?.seek(initialTime); + } }} /> ) : ( )} + {isFullscreen && !!danmakus?.length && ( + + )} + {/* Permanent transparent tap layer — always above Video so taps always reach it */} @@ -400,6 +422,11 @@ export function NativeVideoPlayer({ setShowQuality(true)}> {currentDesc} + {isFullscreen && ( + setShowDanmaku(v => !v)}> + + + )} @@ -436,7 +463,7 @@ export function NativeVideoPlayer({ } const styles = StyleSheet.create({ - container: { width: SCREEN_W, height: VIDEO_H, backgroundColor: '#000' }, + container: { backgroundColor: '#000' }, placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' }, topBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 56, diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 264a025..30758c2 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,10 +1,8 @@ -import React, { useState } from 'react'; -import { View, StyleSheet, Dimensions, Text, Platform, Modal, StatusBar } from 'react-native'; +import React, { useState, useRef, useEffect } from 'react'; +import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native'; +import * as ScreenOrientation from 'expo-screen-orientation'; import { NativeVideoPlayer } from './NativeVideoPlayer'; -import type { PlayUrlResponse } from '../services/types'; - -const { width } = Dimensions.get('window'); -const VIDEO_HEIGHT = width * 0.5625; +import type { PlayUrlResponse, DanmakuItem } from '../services/types'; interface Props { playData: PlayUrlResponse | null; @@ -14,14 +12,38 @@ interface Props { onMiniPlayer?: () => void; bvid?: string; 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 { 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) { return ( - + 视频加载中... ); @@ -30,7 +52,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o if (Platform.OS === 'web') { const url = playData.durl?.[0]?.url ?? ''; return ( - +