This commit is contained in:
Developer
2026-03-12 21:21:01 +08:00
parent 9347c8752d
commit a55bcd46ea
6 changed files with 300 additions and 69 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

View File

@@ -1,4 +1,10 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import React, {
useEffect,
useState,
useRef,
useMemo,
useCallback,
} from "react";
import {
View,
StyleSheet,
@@ -40,18 +46,20 @@ export default function HomeScreen() {
const rows = useMemo(() => toListRows(videos), [videos]);
// useRef-wrapped to satisfy FlatList's requirement that onViewableItemsChanged never changes identity after mount
//
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
const bigRow = viewableItems.find(
(v) => v.item && (v.item as ListRow).type === 'big',
);
setVisibleBigKey(
bigRow ? (bigRow.item as BigRow).item.bvid : null,
(v) => v.item && (v.item as ListRow).type === "big",
);
setVisibleBigKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
},
).current;
const scrollY = useRef(new Animated.Value(0)).current;
const headerTranslate = scrollY.interpolate({
inputRange: [0, NAV_H],
outputRange: [0, -NAV_H],
@@ -62,8 +70,16 @@ export default function HomeScreen() {
load();
}, []);
const onScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
}),
[],
);
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
if (row.type === 'big') {
if (row.type === "big") {
return (
<BigVideoCard
item={row.item}
@@ -92,7 +108,7 @@ export default function HomeScreen() {
)}
</View>
);
}, [visibleBigKey]);
}, []);
return (
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
@@ -100,9 +116,9 @@ export default function HomeScreen() {
style={styles.listContainer}
data={rows}
keyExtractor={(row: any) =>
row.type === 'big'
row.type === "big"
? `big-${row.item.bvid}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? 'empty'}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
}
contentContainerStyle={{
paddingTop: insets.top + NAV_H + 6,
@@ -126,13 +142,9 @@ export default function HomeScreen() {
{loading && <ActivityIndicator color="#00AEEC" />}
</View>
}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true },
)}
onScroll={onScroll}
scrollEventThrottle={16}
/>
{/* 绝对定位导航栏paddingTop 手动适配刘海/状态栏 */}
<Animated.View
style={[
@@ -236,7 +248,11 @@ const styles = StyleSheet.create({
backgroundColor: "#00AEEC",
borderRadius: 1,
},
row: { flexDirection: 'row', paddingHorizontal: 1, justifyContent: "flex-start" },
row: {
flexDirection: "row",
paddingHorizontal: 1,
justifyContent: "flex-start",
},
leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 },
footer: { height: 48, alignItems: "center", justifyContent: "center" },

View File

@@ -1,5 +1,14 @@
import React, { useRef, useMemo, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Animated,
NativeSyntheticEvent,
NativeScrollEvent,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { DanmakuItem } from '../services/types';
import { danmakuColorToCss } from '../utils/danmaku';
@@ -11,19 +20,163 @@ interface Props {
onToggle: () => void;
}
interface DisplayedDanmaku extends DanmakuItem {
_key: number;
_fadeAnim: Animated.Value;
}
const MAX_DISPLAYED = 100;
const DRIP_INTERVAL = 250;
const FAST_DRIP_INTERVAL = 100;
const QUEUE_FAST_THRESHOLD = 50;
const SEEK_THRESHOLD = 2;
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function DanmakuList({ danmakus, currentTime, visible, onToggle }: Props) {
const flatListRef = useRef<FlatList>(null);
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
const [unseenCount, setUnseenCount] = useState(0);
const visibleItems = useMemo(
() => danmakus.filter(d => d.time <= currentTime),
[danmakus, currentTime]
const queueRef = useRef<DanmakuItem[]>([]);
const lastTimeRef = useRef(0);
const processedIndexRef = useRef(0);
const keyCounterRef = useRef(0);
const isAtBottomRef = useRef(true);
const danmakusRef = useRef(danmakus);
// Reset everything when danmakus array reference changes (video switch)
useEffect(() => {
if (danmakusRef.current !== danmakus) {
danmakusRef.current = danmakus;
queueRef.current = [];
processedIndexRef.current = 0;
lastTimeRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
}
}, [danmakus]);
// Watch currentTime, enqueue new danmakus
useEffect(() => {
if (!visible || danmakus.length === 0) return;
const prevTime = lastTimeRef.current;
lastTimeRef.current = currentTime;
// Seek detection
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
queueRef.current = [];
processedIndexRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
// Re-enqueue danmakus up to current time
const catchUp = danmakus.filter(d => d.time <= currentTime);
// Only enqueue recent ones to avoid flooding
const tail = catchUp.slice(-20);
queueRef.current = tail;
processedIndexRef.current = danmakus.findIndex(
d => d.time > currentTime
);
if (processedIndexRef.current === -1) {
processedIndexRef.current = danmakus.length;
}
return;
}
// Normal progression: enqueue danmakus between prevTime and currentTime
const sorted = danmakus; // assumed sorted by time
let i = processedIndexRef.current;
while (i < sorted.length && sorted[i].time <= currentTime) {
queueRef.current.push(sorted[i]);
i++;
}
processedIndexRef.current = i;
}, [currentTime, danmakus, visible]);
// Drip interval: pop from queue, append to displayed
useEffect(() => {
if (!visible) return;
const id = setInterval(() => {
if (queueRef.current.length === 0) return;
const item = queueRef.current.shift()!;
const fadeAnim = new Animated.Value(0);
const displayed: DisplayedDanmaku = {
...item,
_key: keyCounterRef.current++,
_fadeAnim: fadeAnim,
};
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();
setDisplayedItems(prev => {
const next = [...prev, displayed];
return next.length > MAX_DISPLAYED ? next.slice(-MAX_DISPLAYED) : next;
});
if (isAtBottomRef.current) {
// Auto-scroll on next frame
requestAnimationFrame(() => {
flatListRef.current?.scrollToEnd({ animated: true });
});
} else {
setUnseenCount(c => c + 1);
}
}, queueRef.current.length > QUEUE_FAST_THRESHOLD ? FAST_DRIP_INTERVAL : DRIP_INTERVAL);
return () => clearInterval(id);
}, [visible]);
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const distanceFromBottom =
contentSize.height - layoutMeasurement.height - contentOffset.y;
isAtBottomRef.current = distanceFromBottom < 40;
if (isAtBottomRef.current) {
setUnseenCount(0);
}
},
[]
);
useEffect(() => {
if (visible && visibleItems.length > 0) {
flatListRef.current?.scrollToEnd({ animated: true });
}
}, [visibleItems.length, visible]);
const handleScrollBeginDrag = useCallback(() => {
isAtBottomRef.current = false;
}, []);
const handlePillPress = useCallback(() => {
flatListRef.current?.scrollToEnd({ animated: true });
setUnseenCount(0);
isAtBottomRef.current = true;
}, []);
const renderItem = useCallback(({ item }: { item: DisplayedDanmaku }) => {
const dotColor = danmakuColorToCss(item.color);
return (
<Animated.View style={[styles.bubble, { opacity: item._fadeAnim }]}>
<View style={[styles.colorDot, { backgroundColor: dotColor }]} />
<Text style={styles.bubbleText} numberOfLines={3}>
{item.text}
</Text>
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
</Animated.View>
);
}, []);
const keyExtractor = useCallback((item: DisplayedDanmaku) => String(item._key), []);
return (
<View style={styles.container}>
@@ -44,25 +197,34 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle }
</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}
<View style={styles.listWrapper}>
<FlatList
ref={flatListRef}
data={displayedItems}
keyExtractor={keyExtractor}
renderItem={renderItem}
style={styles.list}
contentContainerStyle={styles.listContent}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
removeClippedSubviews={true}
ListEmptyComponent={
<Text style={styles.empty}>
{danmakus.length === 0 ? '暂无弹幕' : '弹幕将随视频播放显示'}
</Text>
}
/>
{unseenCount > 0 && (
<TouchableOpacity
style={styles.pill}
onPress={handlePillPress}
activeOpacity={0.8}
>
{item.text}
</Text>
<Text style={styles.pillText}>{unseenCount} </Text>
</TouchableOpacity>
)}
ListEmptyComponent={
<Text style={styles.empty}>
{danmakus.length === 0 ? '暂无弹幕' : '弹幕将随视频播放显示'}
</Text>
}
/>
</View>
)}
</View>
);
@@ -87,15 +249,61 @@ const styles = StyleSheet.create({
color: '#212121',
fontWeight: '500',
},
list: {
height: 180,
paddingHorizontal: 12,
listWrapper: {
height: 200,
position: 'relative',
},
item: {
list: {
flex: 1,
backgroundColor: '#fafafa',
},
listContent: {
paddingHorizontal: 8,
paddingVertical: 4,
},
bubble: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#f8f8f8',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
marginVertical: 2,
gap: 8,
},
colorDot: {
width: 6,
height: 6,
borderRadius: 3,
marginTop: 6,
flexShrink: 0,
},
bubbleText: {
flex: 1,
fontSize: 13,
paddingVertical: 3,
color: '#333',
lineHeight: 18,
},
timestamp: {
fontSize: 11,
color: '#bbb',
marginTop: 1,
flexShrink: 0,
},
pill: {
position: 'absolute',
bottom: 8,
alignSelf: 'center',
backgroundColor: '#00AEEC',
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 6,
},
pillText: {
fontSize: 12,
color: '#fff',
fontWeight: '600',
},
empty: {
fontSize: 12,
color: '#999',

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start --port 8082",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",

View File

@@ -184,29 +184,29 @@ export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
return parseDanmakuXml(res.data);
}
// Native 策略 1responseType: 'text',依赖 OkHttp 自动解压
// Nativearraybuffer + 逐一尝试解压(服务器强制压缩,无法避免)
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
headers: { Referer: 'https://www.bilibili.com', 'User-Agent': UA },
responseType: 'text',
});
if (typeof res.data === 'string' && res.data.includes('<d ')) {
return parseDanmakuXml(res.data);
}
// 策略 2 回退arraybuffer + pako 手动解压
const res2 = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
headers: { Referer: 'https://www.bilibili.com', 'User-Agent': UA },
responseType: 'arraybuffer',
});
const bytes = new Uint8Array(res2.data as ArrayBuffer);
let xmlText: string;
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
xmlText = pako.inflate(bytes, { to: 'string' });
} else {
const bytes = new Uint8Array(res.data as ArrayBuffer);
let xmlText: string | undefined;
// 依次尝试inflate (gzip/zlib) → inflateRaw (raw deflate)
for (const fn of [pako.inflate, pako.inflateRaw] as Array<(input: Uint8Array, opts: pako.InflateOptions) => string>) {
try {
xmlText = fn(bytes, { to: 'string' });
if (xmlText.includes('<d ')) break;
xmlText = undefined;
} catch { /* 继续尝试下一种 */ }
}
if (!xmlText) {
// 最后尝试当作明文
xmlText = new TextDecoder('utf-8').decode(bytes);
}
return parseDanmakuXml(xmlText);
} catch (e) {
console.warn('getDanmaku failed:', e);

View File

@@ -5,9 +5,13 @@ import { Platform } from 'react-native';
* Native 端直接返回原 URLApp 请求头由 axios 拦截器统一设置)。
*/
export function proxyImageUrl(url: string): string {
if (Platform.OS !== 'web' || !url) return url;
return url.replace(
/^https?:\/\/([a-z0-9]+\.hdslb\.com)/,
'http://localhost:3001/bilibili-img/$1',
);
if (!url) return url;
if (Platform.OS === 'web') {
return url.replace(
/^https?:\/\/([a-z0-9]+\.hdslb\.com)/,
'http://localhost:3001/bilibili-img/$1',
);
}
// Native: force HTTPS so Release APK doesn't block cleartext HTTP
return url.replace(/^http:\/\//, 'https://');
}