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 && (
+ {/* Progress bar between video and info (video only) */}
+ {!isLive && videoUrl && duration > 0 && (
+
+
+
+
+ )}
+
{/* Info */}
@@ -196,6 +312,20 @@ const styles = StyleSheet.create({
borderRadius: 6,
overflow: "hidden",
},
+ liveBadge: {
+ position: "absolute",
+ top: 8,
+ left: 8,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ borderRadius: 5,
+ paddingHorizontal: 5,
+ paddingVertical: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
+ zIndex: 2,
+ },
+ liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" },
durationBadge: {
position: "absolute",
bottom: 4,
@@ -219,6 +349,28 @@ const styles = StyleSheet.create({
justifyContent: "center",
zIndex: 3,
},
+ seekBadge: {
+ position: "absolute",
+ top: "40%",
+ alignSelf: "center",
+ backgroundColor: "rgba(0,0,0,0.6)",
+ borderRadius: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ zIndex: 4,
+ },
+ seekText: { color: "#fff", fontSize: 16, fontWeight: "600" },
+ progressTrack: {
+ height: BAR_H,
+ backgroundColor: "rgba(0,0,0,0.08)",
+ position: "relative",
+ },
+ progressLayer: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ height: BAR_H,
+ },
info: { padding: 8 },
title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
meta: {
diff --git a/components/LiveCard.tsx b/components/LiveCard.tsx
new file mode 100644
index 0000000..6da0d87
--- /dev/null
+++ b/components/LiveCard.tsx
@@ -0,0 +1,135 @@
+import React from "react";
+import {
+ View,
+ Text,
+ Image,
+ TouchableOpacity,
+ StyleSheet,
+ Dimensions,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import type { LiveRoom } from "../services/types";
+import { formatCount } from "../utils/format";
+import { proxyImageUrl } from "../utils/imageUrl";
+import { LivePulse } from "./LivePulse";
+
+const { width } = Dimensions.get("window");
+const CARD_WIDTH = (width - 14) / 2;
+
+interface Props {
+ item: LiveRoom;
+ onPress?: () => void;
+}
+
+export function LiveCard({ item, onPress }: Props) {
+ return (
+
+
+
+
+
+ 直播中
+
+
+
+ {formatCount(item.online)}
+
+
+ {item.area_name}
+
+
+
+
+ {item.title}
+
+
+
+
+ {item.uname}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ width: CARD_WIDTH,
+ marginBottom: 6,
+ backgroundColor: "#fff",
+ borderRadius: 6,
+ overflow: "hidden",
+ },
+ thumbContainer: { position: "relative" },
+ thumb: {
+ width: CARD_WIDTH,
+ height: CARD_WIDTH * 0.5625,
+ backgroundColor: "#ddd",
+ },
+ liveBadge: {
+ position: "absolute",
+ top: 4,
+ left: 4,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ borderRadius: 5,
+ paddingHorizontal: 5,
+ paddingVertical: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
+ },
+ liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" },
+ meta: {
+ position: "absolute",
+ bottom: 4,
+ left: 4,
+ paddingHorizontal: 4,
+ borderRadius: 5,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
+ },
+ metaText: { fontSize: 10, color: "#fff" },
+ areaBadge: {
+ position: "absolute",
+ bottom: 4,
+ right: 4,
+ borderRadius: 5,
+ paddingHorizontal: 4,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ },
+ areaText: { color: "#fff", fontSize: 10 },
+ info: { padding: 6 },
+ title: {
+ fontSize: 12,
+ color: "#212121",
+ height: 33,
+ marginBottom: 4,
+ },
+ ownerRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ marginTop: 2,
+ },
+ avatar: {
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ backgroundColor: "#eee",
+ },
+ owner: { fontSize: 11, color: "#999", flex: 1 },
+});
diff --git a/components/LivePulse.tsx b/components/LivePulse.tsx
new file mode 100644
index 0000000..cf09b36
--- /dev/null
+++ b/components/LivePulse.tsx
@@ -0,0 +1,64 @@
+import React, { useEffect, useRef } from "react";
+import { View, StyleSheet, Animated } from "react-native";
+
+const BAR_COUNT = 3;
+const BAR_HEIGHT = 8;
+
+export function LivePulse() {
+ const anims = useRef(
+ Array.from({ length: BAR_COUNT }, () => new Animated.Value(0.3)),
+ ).current;
+
+ useEffect(() => {
+ const animations = anims.map((anim, i) =>
+ Animated.loop(
+ Animated.sequence([
+ Animated.delay(i * 120),
+ Animated.timing(anim, {
+ toValue: 1,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ Animated.timing(anim, {
+ toValue: 0.3,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ ]),
+ ),
+ );
+ animations.forEach((a) => a.start());
+ return () => animations.forEach((a) => a.stop());
+ }, []);
+
+ return (
+
+ {anims.map((anim, i) => (
+
+ ))}
+
+ );
+}
+
+export const pulseStyles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ height: BAR_HEIGHT,
+ gap: 1,
+ },
+ bar: {
+ width: 2,
+ height: BAR_HEIGHT,
+ backgroundColor: "#fff",
+ borderRadius: 1,
+ },
+});
diff --git a/hooks/useLiveList.ts b/hooks/useLiveList.ts
new file mode 100644
index 0000000..88adc0d
--- /dev/null
+++ b/hooks/useLiveList.ts
@@ -0,0 +1,59 @@
+import { useState, useCallback, useRef } from 'react';
+import { getLiveList } from '../services/bilibili';
+import type { LiveRoom } from '../services/types';
+
+export function useLiveList() {
+ const [rooms, setRooms] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const loadingRef = useRef(false);
+ const pendingRef = useRef(false);
+ const pageRef = useRef(1);
+ const areaIdRef = useRef(0);
+
+ const load = useCallback(async (reset = false, parentAreaId?: number) => {
+ if (loadingRef.current) {
+ if (!reset) pendingRef.current = true;
+ return;
+ }
+ loadingRef.current = true;
+ pendingRef.current = false;
+
+ if (parentAreaId !== undefined) {
+ areaIdRef.current = parentAreaId;
+ }
+
+ if (reset) {
+ pageRef.current = 1;
+ setRooms([]);
+ }
+
+ const page = pageRef.current;
+ setLoading(true);
+ try {
+ const data = await getLiveList(page, areaIdRef.current);
+ setRooms(prev => reset ? data : [...prev, ...data]);
+ pageRef.current = page + 1;
+ } catch (e) {
+ console.error('Failed to load live rooms', e);
+ } finally {
+ loadingRef.current = false;
+ setRefreshing(false);
+
+ if (pendingRef.current) {
+ pendingRef.current = false;
+ load();
+ } else {
+ setLoading(false);
+ }
+ }
+ }, []);
+
+ const refresh = useCallback((parentAreaId?: number) => {
+ setRefreshing(true);
+ load(true, parentAreaId);
+ }, [load]);
+
+ return { rooms, loading, refreshing, load, refresh };
+}
diff --git a/hooks/useVideoList.ts b/hooks/useVideoList.ts
index 0434397..25cc29e 100644
--- a/hooks/useVideoList.ts
+++ b/hooks/useVideoList.ts
@@ -1,9 +1,9 @@
-import { useState, useCallback, useRef } from 'react';
+import { useState, useCallback, useRef, useMemo } from 'react';
import { getRecommendFeed } from '../services/bilibili';
import type { VideoItem } from '../services/types';
export function useVideoList() {
- const [videos, setVideos] = useState([]);
+ const [pages, setPages] = useState([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@@ -18,7 +18,7 @@ export function useVideoList() {
setLoading(true);
try {
const data = await getRecommendFeed(idx);
- setVideos(prev => reset ? data : [...prev, ...data]);
+ setPages(prev => reset ? [data] : [...prev, data]);
freshIdxRef.current = idx + 1;
} catch (e) {
console.error('Failed to load videos', e);
@@ -34,5 +34,7 @@ export function useVideoList() {
load(true);
}, [load]);
- return { videos, loading, refreshing, load, refresh };
+ const videos = useMemo(() => pages.flat(), [pages]);
+
+ return { videos, pages, loading, refreshing, load, refresh };
}
diff --git a/services/bilibili.ts b/services/bilibili.ts
index f7838a9..d7faaef 100644
--- a/services/bilibili.ts
+++ b/services/bilibili.ts
@@ -2,7 +2,7 @@ import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import pako from 'pako';
-import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem } from './types';
+import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom } from './types';
import { signWbi } from '../utils/wbi';
import { parseDanmakuXml } from '../utils/danmaku';
@@ -83,12 +83,39 @@ export async function getRecommendFeed(freshIdx = 0): Promise {
const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed });
const items: any[] = res.data.data?.item ?? [];
return items
- .map(item => ({
- ...item,
- aid: item.id ?? item.aid,
- pic: item.pic ?? item.cover,
- owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
- })) as VideoItem[];
+ .filter(item =>
+ (item.goto === 'av' && item.bvid && item.title) ||
+ (item.goto === 'live' && item.title),
+ )
+ .map(item => {
+ if (item.goto === 'live') {
+ const roomid = item.roomid ?? item.room_id ?? 0;
+ return {
+ bvid: `live-${roomid}`,
+ aid: 0,
+ title: item.title,
+ pic: item.pic ?? item.cover ?? '',
+ owner: item.owner ?? {
+ mid: item.owner_info?.mid ?? item.uid ?? 0,
+ name: item.owner_info?.name ?? item.uname ?? '',
+ face: item.owner_info?.face ?? item.face ?? '',
+ },
+ duration: 0,
+ desc: '',
+ stat: null,
+ goto: 'live' as const,
+ roomid,
+ online: item.watched_show?.num ?? item.online ?? 0,
+ area_name: item.area_name ?? '',
+ } as VideoItem;
+ }
+ return {
+ ...item,
+ aid: item.id ?? item.aid,
+ pic: item.pic ?? item.cover,
+ owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
+ } as VideoItem;
+ });
}
export async function getPopularVideos(pn = 1): Promise {
@@ -166,6 +193,52 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
}
+const LIVE_BASE = isWeb ? 'http://localhost:3001/bilibili-live' : 'https://api.live.bilibili.com';
+
+export async function getLiveList(page = 1, parentAreaId = 0): Promise {
+ if (parentAreaId === 0) {
+ // 推荐:使用原有接口
+ const res = await api.get(`${LIVE_BASE}/xlive/web-interface/v1/webMain/getMoreRecList`, {
+ params: { platform: 'web', page, page_size: 20 },
+ });
+ const list: any[] = res.data.data?.recommend_room_list ?? [];
+ return list.map(item => ({
+ roomid: item.roomid,
+ uid: item.uid,
+ title: item.title,
+ uname: item.uname,
+ face: item.face,
+ cover: item.cover ?? item.user_cover ?? item.keyframe,
+ online: item.online,
+ area_name: item.area_v2_name ?? '',
+ parent_area_name: item.area_v2_parent_name ?? '',
+ }));
+ }
+ // 分区筛选:使用 getRoomList 接口
+ const res = await api.get(`${LIVE_BASE}/room/v1/area/getRoomList`, {
+ params: {
+ parent_area_id: parentAreaId,
+ area_id: 0,
+ page,
+ page_size: 20,
+ sort_type: 'online',
+ platform: 'web',
+ },
+ });
+ const list: any[] = res.data.data ?? [];
+ return list.map(item => ({
+ roomid: item.roomid,
+ uid: item.uid,
+ title: item.title,
+ uname: item.uname,
+ face: item.face,
+ cover: item.cover ?? item.user_cover ?? item.keyframe,
+ online: item.online,
+ area_name: item.area_v2_name ?? item.areaName ?? '',
+ parent_area_name: item.area_v2_parent_name ?? item.parentAreaName ?? '',
+ }));
+}
+
export async function getDanmaku(cid: number): Promise {
try {
if (isWeb) {
diff --git a/services/types.ts b/services/types.ts
index 1bcdebc..952176e 100644
--- a/services/types.ts
+++ b/services/types.ts
@@ -20,6 +20,10 @@ export interface VideoItem {
desc: string;
cid?: number;
pages?: Array<{ cid: number; part: string }>;
+ goto?: 'av' | 'live';
+ roomid?: number;
+ online?: number;
+ area_name?: string;
}
export interface Comment {
@@ -98,3 +102,15 @@ export interface DanmakuItem {
color: number; // 0xRRGGBB 十进制整数
text: string;
}
+
+export interface LiveRoom {
+ roomid: number;
+ uid: number;
+ title: string;
+ uname: string;
+ face: string;
+ cover: string;
+ online: number;
+ area_name: string;
+ parent_area_name: string;
+}
diff --git a/utils/videoRows.ts b/utils/videoRows.ts
index ba22519..0f9104d 100644
--- a/utils/videoRows.ts
+++ b/utils/videoRows.ts
@@ -13,26 +13,20 @@ export interface BigRow {
export type ListRow = NormalRow | BigRow;
-const PAGE = 21; // matches API page size
-
-/**
- * Transform a flat VideoItem array into display rows.
- * Videos are chunked by page size (20). The last item of each chunk
- * becomes a full-width BigRow so BigVideoCards stay at stable positions
- * even as more pages are loaded.
- */
-
-export function toListRows(videos: VideoItem[]): ListRow[] {
- if (videos.length === 0) return [];
+export function toListRows(pages: VideoItem[][]): ListRow[] {
const rows: ListRow[] = [];
- for (let start = 0; start < videos.length; start += PAGE) {
- const chunk = videos.slice(start, start + PAGE);
- // Pick the video with the highest view count as the BigRow
- let bigIdx = 0;
- let maxView = chunk[0].stat?.view ?? 0;
- for (let i = 1; i < chunk.length; i++) {
- const v = chunk[i].stat?.view ?? 0;
- if (v > maxView) { maxView = v; bigIdx = i; }
+ for (const chunk of pages) {
+ if (chunk.length === 0) continue;
+ // Prioritize: first live item becomes BigRow
+ let bigIdx = chunk.findIndex(item => item.goto === 'live');
+ if (bigIdx === -1) {
+ // Fallback: highest view count
+ bigIdx = 0;
+ let maxView = chunk[0].stat?.view ?? 0;
+ for (let i = 1; i < chunk.length; i++) {
+ const v = chunk[i].stat?.view ?? 0;
+ if (v > maxView) { maxView = v; bigIdx = i; }
+ }
}
const bigItem = chunk[bigIdx];
const rest = chunk.filter((_, i) => i !== bigIdx);
@@ -41,6 +35,5 @@ export function toListRows(videos: VideoItem[]): ListRow[] {
}
rows.push({ type: 'big', item: bigItem });
}
-
return rows;
}