Files
JKVideo/app/index.tsx
Developer 3f82646496 init
2026-03-26 12:15:40 +08:00

621 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {
useEffect,
useState,
useRef,
useMemo,
useCallback,
} from "react";
import {
View,
StyleSheet,
Text,
TouchableOpacity,
ActivityIndicator,
Animated,
Image,
RefreshControl,
ViewToken,
FlatList,
ScrollView,
} from "react-native";
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";
import { LiveCard } from "../components/LiveCard";
import { LoginModal } from "../components/LoginModal";
import { DownloadProgressBtn } from "../components/DownloadProgressBtn";
import { useVideoList } from "../hooks/useVideoList";
import { useLiveList } from "../hooks/useLiveList";
import { useAuthStore } from "../store/authStore";
import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard";
import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
import { useTheme } from "../utils/theme";
import type { LiveRoom } from "../services/types";
const HEADER_H = 44;
const TAB_H = 38;
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: "知识" },
];
export default function HomeScreen() {
const router = useRouter();
const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
const {
rooms,
loading: liveLoading,
refreshing: liveRefreshing,
load: liveLoad,
refresh: liveRefresh,
} = useLiveList();
const { isLoggedIn, face } = useAuthStore();
const [showLogin, setShowLogin] = useState(false);
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0);
const theme = useTheme();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const pagerRef = useRef<PagerView>(null);
const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(null);
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);
},
).current;
const scrollY = useRef(new Animated.Value(0)).current;
const headerTranslate = scrollY.interpolate({
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",
});
// 直播列表也共用同一个 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();
}, []);
const onScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
}),
[],
);
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
pagerRef.current?.setPage(key === "hot" ? 0 : 1);
setActiveTab(key);
if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId);
}
},
[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);
}
},
[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 visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey;
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") {
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>
)}
</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
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
)}
</View>
);
}, []);
const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
item={item.left}
onPress={() => router.push(`/live/${item.left.roomid}` as any)}
/>
</View>
{item.right && (
<View style={styles.rightCol}>
<LiveCard
item={item.right}
onPress={() => router.push(`/live/${item.right!.roomid}` as any)}
/>
</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, { backgroundColor: theme.bg }]} edges={["left", "right"]}>
{/* 滑动切换容器 */}
<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}
keyExtractor={(row: any, index: number) =>
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
: `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}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
{/* 直播列表 */}
<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}
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>
</View>
}
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}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
</PagerView>
{/* 绝对定位导航栏 */}
<Animated.View
style={[
styles.navBar,
{
paddingTop: insets.top,
backgroundColor: theme.card,
transform: [{ translateY: currentHeaderTranslate }],
},
]}
>
<Animated.View
style={[
styles.header,
{
opacity: currentHeaderOpacity,
},
]}
>
<View style={styles.headerRight}>
<TouchableOpacity
style={styles.headerBtn}
onPress={() => (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))}
>
{isLoggedIn && face ? (
<Image source={{ uri: face }} style={styles.userAvatar} />
) : (
<Ionicons
name={isLoggedIn ? "person" : "person-outline"}
size={22}
color="#00AEEC"
/>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.searchBar, { backgroundColor: theme.inputBg }]}
onPress={() => router.push("/search" as any)}
activeOpacity={0.7}
>
<Ionicons name="search" size={14} color={theme.textSub} />
<Text style={[styles.searchPlaceholder, { color: theme.textSub }]}>UP主...</Text>
</TouchableOpacity>
<DownloadProgressBtn
onPress={() => router.push("/downloads" as any)}
/>
</Animated.View>
<View style={[styles.tabRow, { backgroundColor: theme.card }]}>
{TABS.map((tab) => (
<TouchableOpacity
key={tab.key}
style={styles.tabItem}
onPress={() => handleTabPress(tab.key)}
activeOpacity={0.7}
>
<Text
style={[
styles.tabText,
{ color: theme.textSub },
activeTab === tab.key && styles.tabTextActive,
]}
>
{tab.label}
</Text>
{activeTab === tab.key && <View style={styles.tabUnderline} />}
</TouchableOpacity>
))}
</View>
</Animated.View>
<LoginModal visible={showLogin} onClose={() => setShowLogin(false)} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: "#f4f4f4" },
pager: { flex: 1 },
listContainer: { flex: 1 },
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 10,
backgroundColor: "#fff",
overflow: "hidden",
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
},
header: {
height: HEADER_H,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
gap: 10,
},
logo: {
fontSize: 20,
fontWeight: "800",
color: "#00AEEC",
letterSpacing: -0.5,
width: 72,
},
searchBar: {
flex: 1,
height: 30,
marginLeft: 8,
backgroundColor: "#f0f0f0",
borderRadius: 15,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
gap: 5,
},
downloadBtn: {},
searchPlaceholder: {
fontSize: 13,
color: "#999",
flex: 1,
},
headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: { paddingLeft: 0 },
userAvatar: {
width: 35,
height: 35,
borderRadius: 50,
backgroundColor: "#eee",
},
tabRow: {
height: TAB_H,
backgroundColor: "#fff",
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 20,
},
tabItem: {
alignItems: "center",
justifyContent: "center",
height: TAB_H,
},
tabText: {
fontSize: 15,
fontWeight: "450",
color: "#999",
},
tabTextActive: {
fontWeight: "500",
color: "#00AEEC",
},
tabUnderline: {
position: "absolute",
bottom: 4,
width: 24,
height: 3,
backgroundColor: "#00AEEC",
borderRadius: 4,
},
row: {
flexDirection: "row",
paddingHorizontal: 1,
justifyContent: "flex-start",
},
leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 },
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",
},
});