直播卡片

This commit is contained in:
Developer
2026-03-13 21:52:09 +08:00
parent b447ec0c76
commit a971a65f96
10 changed files with 860 additions and 100 deletions

View File

@@ -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<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0);
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(videos), [videos]);
const rows = useMemo(() => toListRows(pages), [pages]);
const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(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() {
<BigVideoCard
item={row.item}
isVisible={visibleBigKey === row.item.bvid}
onPress={() => 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 (
<View style={styles.row}>
@@ -118,48 +219,148 @@ export default function HomeScreen() {
[visibleBigKey],
);
const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard item={item.left} />
</View>
{item.right && (
<View style={styles.rightCol}>
<LiveCard item={item.right} />
</View>
)}
</View>
),
[],
);
// 将直播列表分成两列的行
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 (
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
<Animated.FlatList
style={styles.listContainer}
data={rows}
keyExtractor={(row: any) =>
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={
<RefreshControl
refreshing={refreshing}
onRefresh={refresh}
progressViewOffset={insets.top + NAV_H}
/>
}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
extraData={visibleBigKey}
viewabilityConfig={VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChangedRef}
ListFooterComponent={
<View style={styles.footer}>
{loading && <ActivityIndicator color="#00AEEC" />}
</View>
}
onScroll={onScroll}
scrollEventThrottle={16}
/>
{/* 绝对定位导航栏paddingTop 手动适配刘海/状态栏 */}
{/* 热门列表 */}
{activeTab === "hot" && (
<Animated.FlatList
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
keyExtractor={(row: any) =>
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={
<RefreshControl
refreshing={refreshing}
onRefresh={refresh}
progressViewOffset={insets.top + NAV_H}
/>
}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
extraData={visibleBigKey}
viewabilityConfig={VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChangedRef}
ListFooterComponent={
<View style={styles.footer}>
{loading && <ActivityIndicator color="#00AEEC" />}
</View>
}
onScroll={onScroll}
scrollEventThrottle={16}
/>
)}
{/* 直播列表 */}
{activeTab === "live" && (
<Animated.FlatList
ref={liveListRef as any}
style={styles.listContainer}
data={liveRows}
keyExtractor={(item: any, index: number) =>
`live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}`
}
contentContainerStyle={{
paddingTop: insets.top + NAV_H + 6,
paddingBottom: insets.bottom + 16,
}}
renderItem={renderLiveItem}
ListHeaderComponent={
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.areaTabRow}
contentContainerStyle={styles.areaTabContent}
>
{LIVE_AREAS.map((area) => (
<TouchableOpacity
key={area.id}
style={[
styles.areaTab,
liveAreaId === area.id && styles.areaTabActive,
]}
onPress={() => handleLiveAreaPress(area.id)}
activeOpacity={0.7}
>
<Text
style={[
styles.areaTabText,
liveAreaId === area.id && styles.areaTabTextActive,
]}
>
{area.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
}
refreshControl={
<RefreshControl
refreshing={liveRefreshing}
onRefresh={() => liveRefresh(liveAreaId)}
progressViewOffset={insets.top + NAV_H}
/>
}
onEndReached={() => liveLoad()}
onEndReachedThreshold={1.5}
ListFooterComponent={
liveLoading ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
<Text style={styles.footerText}>...</Text>
</View>
) : null
}
onScroll={onLiveScroll}
scrollEventThrottle={16}
/>
)}
{/* 绝对定位导航栏 */}
<Animated.View
style={[
styles.navBar,
{
paddingTop: insets.top,
transform: [{ translateY: headerTranslate }],
transform: [{ translateY: currentHeaderTranslate }],
},
]}
>
@@ -167,7 +368,7 @@ export default function HomeScreen() {
style={[
styles.header,
{
opacity: headerOpacity,
opacity: currentHeaderOpacity,
},
]}
>
@@ -194,8 +395,24 @@ export default function HomeScreen() {
</Animated.View>
<View style={styles.tabRow}>
<Text style={styles.tabActive}></Text>
<View style={styles.tabUnderline} />
{TABS.map((tab) => (
<TouchableOpacity
key={tab.key}
style={styles.tabItem}
onPress={() => handleTabPress(tab.key)}
activeOpacity={0.7}
>
<Text
style={[
styles.tabText,
activeTab === tab.key && styles.tabTextActive,
]}
>
{tab.label}
</Text>
{activeTab === tab.key && <View style={styles.tabUnderline} />}
</TouchableOpacity>
))}
</View>
</Animated.View>
@@ -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",
},
});