Files
JKVideo/app/index.tsx

612 lines
17 KiB
TypeScript
Raw Normal View History

2026-03-12 21:21:01 +08:00
import React, {
useEffect,
useState,
useRef,
useMemo,
useCallback,
} from "react";
import {
View,
StyleSheet,
Text,
TouchableOpacity,
ActivityIndicator,
Animated,
Image,
RefreshControl,
ViewToken,
2026-03-13 21:52:09 +08:00
FlatList,
ScrollView,
} from "react-native";
2026-03-14 18:25:13 +08:00
import PagerView from "react-native-pager-view";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { VideoCard } from "../components/VideoCard";
2026-03-13 21:52:09 +08:00
import { LiveCard } from "../components/LiveCard";
import { LoginModal } from "../components/LoginModal";
2026-03-17 22:18:05 +08:00
import { DownloadProgressBtn } from "../components/DownloadProgressBtn";
import { useVideoList } from "../hooks/useVideoList";
2026-03-13 21:52:09 +08:00
import { useLiveList } from "../hooks/useLiveList";
import { useAuthStore } from "../store/authStore";
2026-03-14 12:27:03 +08:00
import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard";
import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
2026-03-13 21:52:09 +08:00
import type { LiveRoom } from "../services/types";
2026-03-10 19:04:18 +08:00
const HEADER_H = 44;
const TAB_H = 38;
const NAV_H = HEADER_H + TAB_H;
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
2026-03-10 19:04:18 +08:00
2026-03-13 21:52:09 +08:00
type TabKey = "hot" | "live";
const TABS: { key: TabKey; label: string }[] = [
2026-03-20 14:37:14 +08:00
{ key: "hot", label: "热门" },
2026-03-13 21:52:09 +08:00
{ 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: "知识" },
];
export default function HomeScreen() {
const router = useRouter();
2026-03-14 12:27:03 +08:00
const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
2026-03-13 21:52:09 +08:00
const {
rooms,
loading: liveLoading,
refreshing: liveRefreshing,
load: liveLoad,
refresh: liveRefresh,
} = useLiveList();
2026-03-19 16:34:56 +08:00
const { isLoggedIn, face } = useAuthStore();
const [showLogin, setShowLogin] = useState(false);
2026-03-10 19:04:18 +08:00
const insets = useSafeAreaInsets();
2026-03-13 21:52:09 +08:00
const [activeTab, setActiveTab] = useState<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0);
2026-03-10 19:04:18 +08:00
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
2026-03-14 11:55:45 +08:00
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
2026-03-14 18:25:13 +08:00
const pagerRef = useRef<PagerView>(null);
2026-03-13 21:52:09 +08:00
const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(null);
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
const bigRow = viewableItems.find(
2026-03-12 21:21:01 +08:00
(v) => v.item && (v.item as ListRow).type === "big",
);
2026-03-12 21:21:01 +08:00
setVisibleBigKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
},
2026-03-10 19:04:18 +08:00
).current;
const scrollY = useRef(new Animated.Value(0)).current;
2026-03-12 21:21:01 +08:00
const headerTranslate = scrollY.interpolate({
2026-03-12 23:57:01 +08:00
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const headerOpacity = scrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
extrapolate: "clamp",
2026-03-10 19:04:18 +08:00
});
2026-03-13 21:52:09 +08:00
// 直播列表也共用同一个 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();
}, []);
2026-03-12 21:21:01 +08:00
const onScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
}),
[],
);
2026-03-13 21:52:09 +08:00
const onLiveScroll = useMemo(
() =>
2026-03-14 12:27:03 +08:00
Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
useNativeDriver: true,
}),
2026-03-13 21:52:09 +08:00
[],
);
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
2026-03-14 18:25:13 +08:00
pagerRef.current?.setPage(key === "hot" ? 0 : 1);
2026-03-13 21:52:09 +08:00
setActiveTab(key);
if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId);
}
2026-03-14 18:25:13 +08:00
},
[activeTab, rooms.length, liveAreaId],
);
const onPageSelected = useCallback(
(e: any) => {
const key: TabKey = e.nativeEvent.position === 0 ? "hot" : "live";
if (key === activeTab) return;
setActiveTab(key);
if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId);
2026-03-13 21:52:09 +08:00
}
},
[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],
);
2026-03-14 18:25:13 +08:00
const visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey;
2026-03-16 21:13:26 +08:00
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
if (row.type === "big") {
return (
<BigVideoCard
item={row.item}
isVisible={visibleBigKeyRef.current === row.item.bvid}
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
/>
);
}
if (row.type === "live") {
2026-03-12 23:57:01 +08:00
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
isLivePulse
item={row.left}
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View>
{row.right && (
<View style={styles.rightCol}>
<LiveCard
isLivePulse
item={row.right}
onPress={() => router.push(`/live/${row.right!.roomid}` as any)}
/>
</View>
)}
2026-03-16 21:13:26 +08:00
</View>
);
}
const right = row.right;
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={row.left}
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
/>
</View>
{right && (
<View style={styles.rightCol}>
<VideoCard
2026-03-16 21:13:26 +08:00
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
2026-03-16 21:13:26 +08:00
)}
</View>
);
}, []);
2026-03-13 21:52:09 +08:00
const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
<View style={styles.row}>
<View style={styles.leftCol}>
2026-03-16 16:20:59 +08:00
<LiveCard
item={item.left}
onPress={() => router.push(`/live/${item.left.roomid}` as any)}
/>
2026-03-13 21:52:09 +08:00
</View>
{item.right && (
<View style={styles.rightCol}>
2026-03-16 16:20:59 +08:00
<LiveCard
item={item.right}
onPress={() => router.push(`/live/${item.right!.roomid}` as any)}
/>
2026-03-13 21:52:09 +08:00
</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"]}>
2026-03-14 12:27:03 +08:00
{/* 滑动切换容器 */}
2026-03-14 18:25:13 +08:00
<PagerView
ref={pagerRef}
style={styles.pager}
initialPage={0}
scrollEnabled={false}
onPageSelected={onPageSelected}
>
{/* 热门列表 */}
<View key="hot" collapsable={false}>
<Animated.FlatList
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
2026-03-19 21:53:08 +08:00
keyExtractor={(row: any, index: number) =>
2026-03-14 18:25:13 +08:00
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
2026-03-19 21:53:08 +08:00
? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
2026-03-14 18:25:13 +08:00
: `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}
/>
</View>
2026-03-13 21:52:09 +08:00
2026-03-14 18:25:13 +08:00
{/* 直播列表 */}
<View key="live" collapsable={false}>
<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={
<View>
<FollowedLiveStrip />
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.areaTabRow}
contentContainerStyle={styles.areaTabContent}
>
{LIVE_AREAS.map((area) => (
<TouchableOpacity
key={area.id}
2026-03-14 12:27:03 +08:00
style={[
styles.areaTab,
liveAreaId === area.id && styles.areaTabActive,
2026-03-14 12:27:03 +08:00
]}
onPress={() => handleLiveAreaPress(area.id)}
activeOpacity={0.7}
2026-03-14 12:27:03 +08:00
>
<Text
style={[
styles.areaTabText,
liveAreaId === area.id && styles.areaTabTextActive,
]}
>
{area.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
2026-03-14 18:25:13 +08:00
}
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}
/>
</View>
</PagerView>
2026-03-13 21:52:09 +08:00
{/* 绝对定位导航栏 */}
2026-03-10 19:04:18 +08:00
<Animated.View
style={[
styles.navBar,
{
paddingTop: insets.top,
2026-03-13 21:52:09 +08:00
transform: [{ translateY: currentHeaderTranslate }],
},
2026-03-10 19:04:18 +08:00
]}
>
2026-03-12 23:57:01 +08:00
<Animated.View
style={[
styles.header,
{
2026-03-13 21:52:09 +08:00
opacity: currentHeaderOpacity,
2026-03-12 23:57:01 +08:00
},
]}
>
2026-03-10 19:04:18 +08:00
<View style={styles.headerRight}>
<TouchableOpacity
style={styles.headerBtn}
2026-03-19 16:34:56 +08:00
onPress={() => (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))}
2026-03-10 19:04:18 +08:00
>
{isLoggedIn && face ? (
<Image source={{ uri: face }} style={styles.userAvatar} />
) : (
<Ionicons
name={isLoggedIn ? "person" : "person-outline"}
size={22}
color="#00AEEC"
/>
2026-03-10 19:04:18 +08:00
)}
</TouchableOpacity>
</View>
2026-03-16 21:13:26 +08:00
<TouchableOpacity
style={styles.searchBar}
onPress={() => router.push("/search" as any)}
activeOpacity={0.7}
>
<Ionicons name="search" size={14} color="#999" />
<Text style={styles.searchPlaceholder}>UP主...</Text>
</TouchableOpacity>
2026-03-19 12:55:23 +08:00
<DownloadProgressBtn
onPress={() => router.push("/downloads" as any)}
/>
2026-03-12 23:57:01 +08:00
</Animated.View>
2026-03-10 19:04:18 +08:00
<View style={styles.tabRow}>
2026-03-13 21:52:09 +08:00
{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>
))}
2026-03-10 19:04:18 +08:00
</View>
</Animated.View>
<LoginModal visible={showLogin} onClose={() => setShowLogin(false)} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: "#f4f4f4" },
2026-03-14 18:25:13 +08:00
pager: { flex: 1 },
2026-03-10 19:04:18 +08:00
listContainer: { flex: 1 },
navBar: {
position: "absolute",
2026-03-10 19:04:18 +08:00
top: 0,
left: 0,
right: 0,
zIndex: 10,
backgroundColor: "#fff",
overflow: "hidden",
2026-03-10 19:04:18 +08:00
elevation: 2,
shadowColor: "#000",
2026-03-10 19:04:18 +08:00
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
},
header: {
height: HEADER_H,
flexDirection: "row",
alignItems: "center",
2026-03-10 19:04:18 +08:00
paddingHorizontal: 16,
2026-03-16 21:13:26 +08:00
gap: 10,
2026-03-10 19:04:18 +08:00
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#eee",
},
logo: {
fontSize: 20,
fontWeight: "800",
color: "#00AEEC",
letterSpacing: -0.5,
2026-03-16 21:13:26 +08:00
width: 72,
},
searchBar: {
flex: 1,
height: 30,
backgroundColor: "#f0f0f0",
borderRadius: 15,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
gap: 5,
},
downloadBtn: {},
2026-03-16 21:13:26 +08:00
searchPlaceholder: {
fontSize: 13,
color: "#999",
flex: 1,
2026-03-10 19:04:18 +08:00
},
2026-03-14 12:27:03 +08:00
headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: { paddingLeft: 0 },
userAvatar: {
2026-03-13 21:52:09 +08:00
width: 35,
height: 35,
borderRadius: 50,
backgroundColor: "#eee",
},
2026-03-10 19:04:18 +08:00
tabRow: {
height: TAB_H,
backgroundColor: "#fff",
2026-03-10 19:04:18 +08:00
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
2026-03-13 21:52:09 +08:00
gap: 20,
},
tabItem: {
alignItems: "center",
justifyContent: "center",
height: TAB_H,
},
tabText: {
fontSize: 15,
2026-03-17 22:18:05 +08:00
fontWeight: "450",
2026-03-13 21:52:09 +08:00
color: "#999",
},
tabTextActive: {
2026-03-17 22:18:05 +08:00
fontWeight: "500",
2026-03-13 21:52:09 +08:00
color: "#00AEEC",
2026-03-10 19:04:18 +08:00
},
tabUnderline: {
position: "absolute",
2026-03-12 23:57:01 +08:00
bottom: 4,
2026-03-10 19:04:18 +08:00
width: 24,
2026-03-12 23:57:01 +08:00
height: 3,
backgroundColor: "#00AEEC",
2026-03-12 23:57:01 +08:00
borderRadius: 4,
2026-03-10 19:04:18 +08:00
},
2026-03-12 21:21:01 +08:00
row: {
flexDirection: "row",
paddingHorizontal: 1,
justifyContent: "flex-start",
},
leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 },
2026-03-14 12:27:03 +08:00
footer: {
height: 48,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 6,
},
2026-03-13 21:52:09 +08:00
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",
},
});