From a971a65f9694f7e0ac0a6dc7bb508d5dfc7c87a4 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 13 Mar 2026 21:52:09 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E6=92=AD=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 12 +- app/index.tsx | 366 ++++++++++++++++++++++++++++++------ components/BigVideoCard.tsx | 178 ++++++++++++++++-- components/LiveCard.tsx | 135 +++++++++++++ components/LivePulse.tsx | 64 +++++++ hooks/useLiveList.ts | 59 ++++++ hooks/useVideoList.ts | 10 +- services/bilibili.ts | 87 ++++++++- services/types.ts | 16 ++ utils/videoRows.ts | 33 ++-- 10 files changed, 860 insertions(+), 100 deletions(-) create mode 100644 components/LiveCard.tsx create mode 100644 components/LivePulse.tsx create mode 100644 hooks/useLiveList.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 47ddb9a..e3a5715 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -17,7 +17,17 @@ export default function RootLayout() { - + + + + diff --git a/app/index.tsx b/app/index.tsx index fa8e154..4fda111 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -15,6 +15,9 @@ import { Image, RefreshControl, ViewToken, + FlatList, + ScrollView, + Linking, } from "react-native"; import { SafeAreaView, @@ -23,11 +26,14 @@ import { import { useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { VideoCard } from "../components/VideoCard"; +import { LiveCard } from "../components/LiveCard"; import { LoginModal } from "../components/LoginModal"; import { useVideoList } from "../hooks/useVideoList"; +import { useLiveList } from "../hooks/useLiveList"; import { useAuthStore } from "../store/authStore"; import { toListRows, type ListRow, type BigRow } from "../utils/videoRows"; import { BigVideoCard } from "../components/BigVideoCard"; +import type { LiveRoom } from "../services/types"; const HEADER_H = 44; const TAB_H = 38; @@ -35,15 +41,46 @@ const NAV_H = HEADER_H + TAB_H; const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 }; +type TabKey = "hot" | "live"; + +const TABS: { key: TabKey; label: string }[] = [ + { key: "hot", label: "热门" }, + { key: "live", label: "直播" }, +]; + +const LIVE_AREAS = [ + { id: 0, name: "推荐" }, + { id: 2, name: "网游" }, + { id: 3, name: "手游" }, + { id: 6, name: "单机游戏" }, + { id: 1, name: "娱乐" }, + { id: 9, name: "虚拟主播" }, + { id: 10, name: "生活" }, + { id: 11, name: "知识" }, + { id: 13, name: "赛事" }, +]; + export default function HomeScreen() { const router = useRouter(); - const { videos, loading, refreshing, load, refresh } = useVideoList(); + const { pages, loading, refreshing, load, refresh } = useVideoList(); + const { + rooms, + loading: liveLoading, + refreshing: liveRefreshing, + load: liveLoad, + refresh: liveRefresh, + } = useLiveList(); const { isLoggedIn, face, logout } = useAuthStore(); const [showLogin, setShowLogin] = useState(false); const insets = useSafeAreaInsets(); + const [activeTab, setActiveTab] = useState("hot"); + const [liveAreaId, setLiveAreaId] = useState(0); const [visibleBigKey, setVisibleBigKey] = useState(null); - const rows = useMemo(() => toListRows(videos), [videos]); + const rows = useMemo(() => toListRows(pages), [pages]); + + const hotListRef = useRef(null); + const liveListRef = useRef(null); const onViewableItemsChangedRef = useRef( ({ viewableItems }: { viewableItems: ViewToken[] }) => { @@ -56,9 +93,6 @@ export default function HomeScreen() { const scrollY = useRef(new Animated.Value(0)).current; - // 阻尼限制 - const diffClamp = Animated.diffClamp(scrollY, 0, HEADER_H); - const headerTranslate = scrollY.interpolate({ inputRange: [0, HEADER_H], outputRange: [0, -HEADER_H], @@ -71,6 +105,21 @@ export default function HomeScreen() { extrapolate: "clamp", }); + // 直播列表也共用同一个 scrollY + const liveScrollY = useRef(new Animated.Value(0)).current; + + const liveHeaderTranslate = liveScrollY.interpolate({ + inputRange: [0, HEADER_H], + outputRange: [0, -HEADER_H], + extrapolate: "clamp", + }); + + const liveHeaderOpacity = liveScrollY.interpolate({ + inputRange: [0, HEADER_H * 0.2], + outputRange: [1, 0], + extrapolate: "clamp", + }); + useEffect(() => { load(); }, []); @@ -83,6 +132,53 @@ export default function HomeScreen() { [], ); + const onLiveScroll = useMemo( + () => + Animated.event( + [{ nativeEvent: { contentOffset: { y: liveScrollY } } }], + { useNativeDriver: true }, + ), + [], + ); + + const handleTabPress = useCallback( + (key: TabKey) => { + if (key === activeTab) { + // 点击已激活的 tab:滚动到顶部并刷新 + if (key === "hot") { + hotListRef.current?.scrollToOffset({ offset: 0, animated: true }); + refresh(); + } else { + liveListRef.current?.scrollToOffset({ offset: 0, animated: true }); + liveRefresh(liveAreaId); + } + return; + } + // 切换 tab + setActiveTab(key); + if (key === "live" && rooms.length === 0) { + liveLoad(true, liveAreaId); + } + // 重置 header 动画 + if (key === "hot") { + scrollY.setValue(0); + } else { + liveScrollY.setValue(0); + } + }, + [activeTab, rooms.length, liveAreaId], + ); + + const handleLiveAreaPress = useCallback( + (areaId: number) => { + if (areaId === liveAreaId) return; + setLiveAreaId(areaId); + liveListRef.current?.scrollToOffset({ offset: 0, animated: false }); + liveLoad(true, areaId); + }, + [liveAreaId, liveLoad], + ); + const renderItem = useCallback( ({ item: row }: { item: ListRow }) => { if (row.type === "big") { @@ -90,11 +186,16 @@ export default function HomeScreen() { router.push(`/video/${row.item.bvid}` as any)} + onPress={() => { + if (row.item.goto === 'live' && row.item.roomid) { + Linking.openURL(`https://live.bilibili.com/${row.item.roomid}`); + } else { + router.push(`/video/${row.item.bvid}` as any); + } + }} /> ); } - // Normal pair row const right = row.right; return ( @@ -118,48 +219,148 @@ export default function HomeScreen() { [visibleBigKey], ); + const renderLiveItem = useCallback( + ({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => ( + + + + + {item.right && ( + + + + )} + + ), + [], + ); + + // 将直播列表分成两列的行 + const liveRows = useMemo(() => { + const result: { left: LiveRoom; right?: LiveRoom }[] = []; + for (let i = 0; i < rooms.length; i += 2) { + result.push({ left: rooms[i], right: rooms[i + 1] }); + } + return result; + }, [rooms]); + + const currentHeaderTranslate = + activeTab === "hot" ? headerTranslate : liveHeaderTranslate; + const currentHeaderOpacity = + activeTab === "hot" ? headerOpacity : liveHeaderOpacity; + return ( - - row.type === "big" - ? `big-${row.item.bvid}` - : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` - } - contentContainerStyle={{ - paddingTop: insets.top + NAV_H + 6, - paddingBottom: insets.bottom + 16, - }} - renderItem={renderItem} - refreshControl={ - - } - onEndReached={() => load()} - onEndReachedThreshold={0.5} - extraData={visibleBigKey} - viewabilityConfig={VIEWABILITY_CONFIG} - onViewableItemsChanged={onViewableItemsChangedRef} - ListFooterComponent={ - - {loading && } - - } - onScroll={onScroll} - scrollEventThrottle={16} - /> - {/* 绝对定位导航栏:paddingTop 手动适配刘海/状态栏 */} + {/* 热门列表 */} + {activeTab === "hot" && ( + + row.type === "big" + ? `big-${row.item.bvid}` + : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` + } + contentContainerStyle={{ + paddingTop: insets.top + NAV_H + 6, + paddingBottom: insets.bottom + 16, + }} + renderItem={renderItem} + refreshControl={ + + } + onEndReached={() => load()} + onEndReachedThreshold={0.5} + extraData={visibleBigKey} + viewabilityConfig={VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChangedRef} + ListFooterComponent={ + + {loading && } + + } + onScroll={onScroll} + scrollEventThrottle={16} + /> + )} + + {/* 直播列表 */} + {activeTab === "live" && ( + + `live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}` + } + contentContainerStyle={{ + paddingTop: insets.top + NAV_H + 6, + paddingBottom: insets.bottom + 16, + }} + renderItem={renderLiveItem} + ListHeaderComponent={ + + {LIVE_AREAS.map((area) => ( + handleLiveAreaPress(area.id)} + activeOpacity={0.7} + > + + {area.name} + + + ))} + + } + refreshControl={ + liveRefresh(liveAreaId)} + progressViewOffset={insets.top + NAV_H} + /> + } + onEndReached={() => liveLoad()} + onEndReachedThreshold={1.5} + ListFooterComponent={ + liveLoading ? ( + + + 加载中... + + ) : null + } + onScroll={onLiveScroll} + scrollEventThrottle={16} + /> + )} + + {/* 绝对定位导航栏 */} @@ -167,7 +368,7 @@ export default function HomeScreen() { style={[ styles.header, { - opacity: headerOpacity, + opacity: currentHeaderOpacity, }, ]} > @@ -194,8 +395,24 @@ export default function HomeScreen() { - 热门 - + {TABS.map((tab) => ( + handleTabPress(tab.key)} + activeOpacity={0.7} + > + + {tab.label} + + {activeTab === tab.key && } + + ))} @@ -215,9 +432,7 @@ const styles = StyleSheet.create({ zIndex: 10, backgroundColor: "#fff", overflow: "hidden", - // 安卓投影 elevation: 2, - // iOS 投影 shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, @@ -238,12 +453,12 @@ const styles = StyleSheet.create({ color: "#00AEEC", letterSpacing: -0.5, }, - headerRight: { flexDirection: "row", gap: 8 }, - headerBtn: { padding: 6 }, + headerRight: { flexDirection: "row", gap: 8,alignItems: "center" }, + headerBtn: {paddingLeft:0 }, userAvatar: { - width: 28, - height: 28, - borderRadius: 14, + width: 35, + height: 35, + borderRadius: 50, backgroundColor: "#eee", }, tabRow: { @@ -252,12 +467,25 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, flexDirection: "row", alignItems: "center", + gap: 20, + }, + tabItem: { + alignItems: "center", + justifyContent: "center", + height: TAB_H, + }, + tabText: { + fontSize: 15, + fontWeight: "500", + color: "#999", + }, + tabTextActive: { + fontWeight: "700", + color: "#00AEEC", }, - tabActive: { fontSize: 15, fontWeight: "700", color: "#00AEEC" }, tabUnderline: { position: "absolute", bottom: 4, - left: 20, width: 24, height: 3, backgroundColor: "#00AEEC", @@ -270,5 +498,33 @@ const styles = StyleSheet.create({ }, leftCol: { marginLeft: 4, marginRight: 2 }, rightCol: { marginLeft: 2, marginRight: 4 }, - footer: { height: 48, alignItems: "center", justifyContent: "center" }, + footer: { height: 48, alignItems: "center", justifyContent: "center", flexDirection: "row", gap: 6 }, + footerText: { fontSize: 12, color: "#999" }, + areaTabRow: { + marginBottom: 6, + }, + areaTabContent: { + paddingHorizontal: 8, + gap: 8, + alignItems: "center", + height: 36, + }, + areaTab: { + paddingHorizontal: 10, + paddingVertical: 2, + borderRadius: 16, + backgroundColor: "#f0f0f0", + }, + areaTabActive: { + backgroundColor: "#00AEEC", + }, + areaTabText: { + fontSize: 13, + color: "#333", + fontWeight: "500", + }, + areaTabTextActive: { + color: "#fff", + fontWeight: "600", + }, }); diff --git a/components/BigVideoCard.tsx b/components/BigVideoCard.tsx index 1083859..6925646 100644 --- a/components/BigVideoCard.tsx +++ b/components/BigVideoCard.tsx @@ -1,5 +1,5 @@ // components/BigVideoCard.tsx -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState, useCallback } from "react"; import { View, Text, @@ -8,13 +8,15 @@ import { StyleSheet, useWindowDimensions, Animated, + PanResponder, } from "react-native"; -import Video from "react-native-video"; +import Video, { VideoRef } from "react-native-video"; import { Ionicons } from "@expo/vector-icons"; import { buildDashMpdUri } from "../utils/dash"; import { getPlayUrl, getVideoDetail } from "../services/bilibili"; import { proxyImageUrl } from "../utils/imageUrl"; import { formatCount, formatDuration } from "../utils/format"; +import { LivePulse } from "./LivePulse"; import type { VideoItem } from "../services/types"; const HEADERS = { @@ -23,6 +25,16 @@ const HEADERS = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; +const BAR_H = 3; +// Minimum horizontal distance (px) before treating the gesture as a seek +const SWIPE_THRESHOLD = 8; +// Full swipe across the screen = seek this many seconds +const SWIPE_SECONDS = 90; + +function clamp(v: number, lo: number, hi: number) { + return Math.max(lo, Math.min(hi, v)); +} + interface Props { item: VideoItem; isVisible: boolean; @@ -40,22 +52,38 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { const [muted, setMuted] = useState(true); const thumbOpacity = useRef(new Animated.Value(1)).current; + const videoRef = useRef(null); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + + // Refs for PanResponder (avoid stale closures) + const currentTimeRef = useRef(0); + const durationRef = useRef(0); + const seekingRef = useRef(false); + const [seekLabel, setSeekLabel] = useState(null); + // Reset video state when the item changes useEffect(() => { setVideoUrl(undefined); setIsDash(false); setPaused(true); setMuted(true); + setCurrentTime(0); + setDuration(0); + setBuffered(0); thumbOpacity.setValue(1); }, [item.bvid]); + const isLive = item.goto === 'live'; + // Fetch play URL when visible for the first time useEffect(() => { + if (isLive) return; if (!isVisible || videoUrl) return; let cancelled = false; (async () => { try { - // cid may be missing from feed items; fetch detail if needed let cid = item.cid; if (!cid) { const detail = await getVideoDetail(item.bvid); @@ -82,7 +110,6 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { return () => { cancelled = true; }; - // videoUrl intentionally excluded — re-fetch guard prevents redundant fetches after URL is set }, [isVisible, item.bvid]); // Pause/resume when visibility changes @@ -91,7 +118,6 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { setPaused(!isVisible); if (!isVisible) { setMuted(true); - // Restore thumbnail when leaving viewport Animated.timing(thumbOpacity, { toValue: 1, duration: 150, @@ -110,6 +136,50 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { }).start(); }; + // Keep refs in sync + useEffect(() => { + currentTimeRef.current = currentTime; + }, [currentTime]); + useEffect(() => { + durationRef.current = duration; + }, [duration]); + + // Horizontal swipe to seek + const swipeStartTime = useRef(0); + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gs) => + Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy), + onPanResponderGrant: () => { + seekingRef.current = true; + swipeStartTime.current = currentTimeRef.current; + }, + onPanResponderMove: (_, gs) => { + if (durationRef.current <= 0) return; + const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS; + const target = clamp(swipeStartTime.current + delta, 0, durationRef.current); + setSeekLabel(formatDuration(Math.floor(target))); + }, + onPanResponderRelease: (_, gs) => { + if (durationRef.current > 0) { + const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS; + const target = clamp(swipeStartTime.current + delta, 0, durationRef.current); + videoRef.current?.seek(target); + setCurrentTime(target); + } + seekingRef.current = false; + setSeekLabel(null); + }, + onPanResponderTerminate: () => { + seekingRef.current = false; + setSeekLabel(null); + }, + }), + ).current; + + const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; + const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0; + return ( {/* Media area */} @@ -117,6 +187,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { {/* Video player — rendered first so it sits behind the thumbnail */} {videoUrl && (