mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
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:
103
components/DanmakuList.tsx
Normal file
103
components/DanmakuList.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
170
components/DanmakuOverlay.tsx
Normal file
170
components/DanmakuOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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<string | undefined>();
|
||||
const isDash = !!playData?.dash;
|
||||
|
||||
@@ -143,12 +148,13 @@ export function NativeVideoPlayer({
|
||||
const isSeekingRef = useRef(false);
|
||||
const [touchX, setTouchX] = useState<number | null>(null);
|
||||
const barOffsetX = useRef(0);
|
||||
const barWidthRef = useRef(SCREEN_W);
|
||||
const barWidthRef = useRef(300);
|
||||
const trackRef = useRef<View>(null);
|
||||
|
||||
const [heatSegments, setHeatSegments] = useState<number[]>([]);
|
||||
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||
const [shotTimestamps, setShotTimestamps] = useState<number[]>([]);
|
||||
const [showDanmaku, setShowDanmaku] = useState(true);
|
||||
|
||||
const videoRef = useRef<VideoRef>(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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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 */}
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
@@ -400,6 +422,11 @@ export function NativeVideoPlayer({
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</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}>
|
||||
<Ionicons name="expand" size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<View style={[styles.container, styles.placeholder]}>
|
||||
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}>
|
||||
<Text style={styles.placeholderText}>视频加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -30,7 +52,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
if (Platform.OS === 'web') {
|
||||
const url = playData.durl?.[0]?.url ?? '';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
|
||||
<video
|
||||
src={url}
|
||||
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
|
||||
@@ -43,29 +65,37 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
|
||||
return (
|
||||
<>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={() => setFullscreen(true)}
|
||||
onMiniPlayer={onMiniPlayer}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
/>
|
||||
{/* Portrait player: unmount when fullscreen */}
|
||||
{!fullscreen && (
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={handleEnterFullscreen}
|
||||
onMiniPlayer={onMiniPlayer}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
isFullscreen={false}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={styles.fullscreenContainer}>
|
||||
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={() => setFullscreen(false)}
|
||||
onFullscreen={handleExitFullscreen}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
style={{ width: '100%', height: '100%' } as any}
|
||||
danmakus={danmakus}
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -74,8 +104,6 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
|
||||
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { color: '#fff', fontSize: 14 },
|
||||
fullscreenContainer: { flex: 1, backgroundColor: '#000' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user