diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/app/index.tsx b/app/index.tsx index 8ecba22..a2730f1 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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 ( ); - }, [visibleBigKey]); + }, []); return ( @@ -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 && } } - onScroll={Animated.event( - [{ nativeEvent: { contentOffset: { y: scrollY } } }], - { useNativeDriver: true }, - )} + onScroll={onScroll} scrollEventThrottle={16} /> - {/* 绝对定位导航栏:paddingTop 手动适配刘海/状态栏 */} 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(null); + const [displayedItems, setDisplayedItems] = useState([]); + const [unseenCount, setUnseenCount] = useState(0); - const visibleItems = useMemo( - () => danmakus.filter(d => d.time <= currentTime), - [danmakus, currentTime] + const queueRef = useRef([]); + 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) => { + 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 ( + + + + {item.text} + + {formatTimestamp(item.time)} + + ); + }, []); + + const keyExtractor = useCallback((item: DisplayedDanmaku) => String(item._key), []); return ( @@ -44,25 +197,34 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle } {visible && ( - `${item.time}_${item.text}_${i}`} - style={styles.list} - renderItem={({ item }) => ( - + + {danmakus.length === 0 ? '暂无弹幕' : '弹幕将随视频播放显示'} + + } + /> + {unseenCount > 0 && ( + - {item.text} - + {unseenCount} 条新弹幕 + )} - ListEmptyComponent={ - - {danmakus.length === 0 ? '暂无弹幕' : '弹幕将随视频播放显示'} - - } - /> + )} ); @@ -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', diff --git a/package.json b/package.json index 10af76e..af08b63 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/services/bilibili.ts b/services/bilibili.ts index e756541..2f4f5a3 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -184,29 +184,29 @@ export async function getDanmaku(cid: number): Promise { return parseDanmakuXml(res.data); } - // Native 策略 1:responseType: 'text',依赖 OkHttp 自动解压 + // Native:arraybuffer + 逐一尝试解压(服务器强制压缩,无法避免) 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(' string>) { + try { + xmlText = fn(bytes, { to: 'string' }); + if (xmlText.includes('