This commit is contained in:
Developer
2026-03-14 12:27:03 +08:00
parent e9b00d026f
commit fb819798b1
5 changed files with 207 additions and 161 deletions

View File

@@ -18,6 +18,7 @@ import {
FlatList, FlatList,
ScrollView, ScrollView,
Linking, Linking,
useWindowDimensions,
} from "react-native"; } from "react-native";
import { import {
SafeAreaView, SafeAreaView,
@@ -31,7 +32,12 @@ import { LoginModal } from "../components/LoginModal";
import { useVideoList } from "../hooks/useVideoList"; import { useVideoList } from "../hooks/useVideoList";
import { useLiveList } from "../hooks/useLiveList"; import { useLiveList } from "../hooks/useLiveList";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { toListRows, type ListRow, type BigRow, type LiveRow } from "../utils/videoRows"; import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard"; import { BigVideoCard } from "../components/BigVideoCard";
import type { LiveRoom } from "../services/types"; import type { LiveRoom } from "../services/types";
@@ -62,7 +68,8 @@ const LIVE_AREAS = [
export default function HomeScreen() { export default function HomeScreen() {
const router = useRouter(); const router = useRouter();
const { pages, liveRooms, loading, refreshing, load, refresh } = useVideoList(); const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
const { const {
rooms, rooms,
loading: liveLoading, loading: liveLoading,
@@ -76,8 +83,10 @@ export default function HomeScreen() {
const [activeTab, setActiveTab] = useState<TabKey>("hot"); const [activeTab, setActiveTab] = useState<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0); const [liveAreaId, setLiveAreaId] = useState(0);
const { width: SCREEN_W } = useWindowDimensions();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null); const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]); const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const tabAnim = useRef(new Animated.Value(0)).current;
const hotListRef = useRef<FlatList>(null); const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(null); const liveListRef = useRef<FlatList>(null);
@@ -134,10 +143,9 @@ export default function HomeScreen() {
const onLiveScroll = useMemo( const onLiveScroll = useMemo(
() => () =>
Animated.event( Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
[{ nativeEvent: { contentOffset: { y: liveScrollY } } }], useNativeDriver: true,
{ useNativeDriver: true }, }),
),
[], [],
); );
@@ -156,6 +164,11 @@ export default function HomeScreen() {
} }
// 切换 tab // 切换 tab
setActiveTab(key); setActiveTab(key);
Animated.timing(tabAnim, {
toValue: key === "hot" ? 0 : 1,
duration: 250,
useNativeDriver: true,
}).start();
if (key === "live" && rooms.length === 0) { if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId); liveLoad(true, liveAreaId);
} }
@@ -192,21 +205,15 @@ export default function HomeScreen() {
} }
if (row.type === "live") { if (row.type === "live") {
return ( return (
<View style={styles.row}> <View style={styles.liveRow}>
<View style={styles.leftCol}> <LiveCard
<LiveCard isLivePulse
item={row.left} item={row.left}
onPress={() => Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)} fullWidth
/> onPress={() =>
</View> Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)
{row.right && ( }
<View style={styles.rightCol}> />
<LiveCard
item={row.right}
onPress={() => Linking.openURL(`https://live.bilibili.com/${row.right!.roomid}`)}
/>
</View>
)}
</View> </View>
); );
} }
@@ -237,11 +244,11 @@ export default function HomeScreen() {
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => ( ({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
<View style={styles.row}> <View style={styles.row}>
<View style={styles.leftCol}> <View style={styles.leftCol}>
<LiveCard item={item.left} /> <LiveCard item={item.left} />
</View> </View>
{item.right && ( {item.right && (
<View style={styles.rightCol}> <View style={styles.rightCol}>
<LiveCard item={item.right} /> <LiveCard item={item.right} />
</View> </View>
)} )}
</View> </View>
@@ -263,112 +270,127 @@ export default function HomeScreen() {
const currentHeaderOpacity = const currentHeaderOpacity =
activeTab === "hot" ? headerOpacity : liveHeaderOpacity; activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
const tabSlideX = tabAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, -SCREEN_W],
});
return ( return (
<SafeAreaView style={styles.safe} edges={["left", "right"]}> <SafeAreaView style={styles.safe} edges={["left", "right"]}>
{/* 热门列表 */} {/* 滑动切换容器 */}
{activeTab === "hot" && ( <View style={styles.tabSlideOuter}>
<Animated.FlatList <Animated.View
ref={hotListRef as any} style={[
style={styles.listContainer} styles.tabSlideRow,
data={rows} { width: SCREEN_W * 2, transform: [{ translateX: tabSlideX }] },
keyExtractor={(row: any) => ]}
row.type === "big" >
? `big-${row.item.bvid}` {/* 热门列表 */}
: row.type === "live" <View style={{ width: SCREEN_W, height: "100%" }}>
? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}` <Animated.FlatList
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` ref={hotListRef as any}
} style={styles.listContainer}
contentContainerStyle={{ data={rows}
paddingTop: insets.top + NAV_H + 6, keyExtractor={(row: any) =>
paddingBottom: insets.bottom + 16, row.type === "big"
}} ? `big-${row.item.bvid}`
renderItem={renderItem} : row.type === "live"
refreshControl={ ? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
<RefreshControl : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
refreshing={refreshing} }
onRefresh={refresh} contentContainerStyle={{
progressViewOffset={insets.top + NAV_H} 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>
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" && ( <View style={{ width: SCREEN_W, height: "100%" }}>
<Animated.FlatList <Animated.FlatList
ref={liveListRef as any} ref={liveListRef as any}
style={styles.listContainer} style={styles.listContainer}
data={liveRows} data={liveRows}
keyExtractor={(item: any, index: number) => keyExtractor={(item: any, index: number) =>
`live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}` `live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}`
} }
contentContainerStyle={{ contentContainerStyle={{
paddingTop: insets.top + NAV_H + 6, paddingTop: insets.top + NAV_H + 6,
paddingBottom: insets.bottom + 16, paddingBottom: insets.bottom + 16,
}} }}
renderItem={renderLiveItem} renderItem={renderLiveItem}
ListHeaderComponent={ ListHeaderComponent={
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={styles.areaTabRow} style={styles.areaTabRow}
contentContainerStyle={styles.areaTabContent} 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 {LIVE_AREAS.map((area) => (
style={[ <TouchableOpacity
styles.areaTabText, key={area.id}
liveAreaId === area.id && styles.areaTabTextActive, style={[
]} styles.areaTab,
> liveAreaId === area.id && styles.areaTabActive,
{area.name} ]}
</Text> onPress={() => handleLiveAreaPress(area.id)}
</TouchableOpacity> activeOpacity={0.7}
))} >
</ScrollView> <Text
} style={[
refreshControl={ styles.areaTabText,
<RefreshControl liveAreaId === area.id && styles.areaTabTextActive,
refreshing={liveRefreshing} ]}
onRefresh={() => liveRefresh(liveAreaId)} >
progressViewOffset={insets.top + NAV_H} {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}
/> />
} </View>
onEndReached={() => liveLoad()} </Animated.View>
onEndReachedThreshold={1.5} </View>
ListFooterComponent={
liveLoading ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
<Text style={styles.footerText}>...</Text>
</View>
) : null
}
onScroll={onLiveScroll}
scrollEventThrottle={16}
/>
)}
{/* 绝对定位导航栏 */} {/* 绝对定位导航栏 */}
<Animated.View <Animated.View
@@ -439,6 +461,8 @@ export default function HomeScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: "#f4f4f4" }, safe: { flex: 1, backgroundColor: "#f4f4f4" },
tabSlideOuter: { flex: 1, overflow: "hidden" },
tabSlideRow: { flexDirection: "row", height: "100%" },
listContainer: { flex: 1 }, listContainer: { flex: 1 },
navBar: { navBar: {
position: "absolute", position: "absolute",
@@ -469,8 +493,8 @@ const styles = StyleSheet.create({
color: "#00AEEC", color: "#00AEEC",
letterSpacing: -0.5, letterSpacing: -0.5,
}, },
headerRight: { flexDirection: "row", gap: 8,alignItems: "center" }, headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: {paddingLeft:0 }, headerBtn: { paddingLeft: 0 },
userAvatar: { userAvatar: {
width: 35, width: 35,
height: 35, height: 35,
@@ -514,7 +538,16 @@ const styles = StyleSheet.create({
}, },
leftCol: { marginLeft: 4, marginRight: 2 }, leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 }, rightCol: { marginLeft: 2, marginRight: 4 },
footer: { height: 48, alignItems: "center", justifyContent: "center", flexDirection: "row", gap: 6 }, liveRow: {
paddingHorizontal: 4,
},
footer: {
height: 48,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 6,
},
footerText: { fontSize: 12, color: "#999" }, footerText: { fontSize: 12, color: "#999" },
areaTabRow: { areaTabRow: {
marginBottom: 6, marginBottom: 6,

View File

@@ -146,6 +146,8 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
PanResponder.create({ PanResponder.create({
onMoveShouldSetPanResponder: (_, gs) => onMoveShouldSetPanResponder: (_, gs) =>
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy), Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
onMoveShouldSetPanResponderCapture: (_, gs) =>
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
onPanResponderGrant: () => { onPanResponderGrant: () => {
seekingRef.current = true; seekingRef.current = true;
swipeStartTime.current = currentTimeRef.current; swipeStartTime.current = currentTimeRef.current;
@@ -255,23 +257,25 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
)} )}
</View> </View>
{/* Progress bar between video and info */} {/* Progress bar between video and info — always rendered to avoid height jump */}
{videoUrl && duration > 0 && ( <View style={styles.progressTrack}>
<View style={styles.progressTrack}> {videoUrl && duration > 0 && (
<View <>
style={[ <View
styles.progressLayer, style={[
{ width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(0,174,236,0.25)" }, styles.progressLayer,
]} { width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(0,174,236,0.25)" },
/> ]}
<View />
style={[ <View
styles.progressLayer, style={[
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" }, styles.progressLayer,
]} { width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
/> ]}
</View> />
)} </>
)}
</View>
{/* Info */} {/* Info */}
<View style={styles.info}> <View style={styles.info}>

View File

@@ -18,24 +18,35 @@ const CARD_WIDTH = (width - 14) / 2;
interface Props { interface Props {
item: LiveRoom; item: LiveRoom;
isLivePulse?: Boolean;
onPress?: () => void; onPress?: () => void;
fullWidth?: boolean;
} }
export function LiveCard({ item, onPress }: Props) { export function LiveCard({
item,
onPress,
fullWidth,
isLivePulse = false,
}: Props) {
const cardWidth = fullWidth ? width - 8 : CARD_WIDTH;
return ( return (
<TouchableOpacity <TouchableOpacity
style={styles.card} style={[styles.card, { width: cardWidth }]}
onPress={onPress} onPress={onPress}
activeOpacity={0.85} activeOpacity={0.85}
> >
<View style={styles.thumbContainer}> <View style={styles.thumbContainer}>
<Image <Image
source={{ uri: proxyImageUrl(item.cover) }} source={{ uri: proxyImageUrl(item.cover) }}
style={styles.thumb} style={[
styles.thumb,
{ width: cardWidth, height: cardWidth * 0.5625 },
]}
resizeMode="cover" resizeMode="cover"
/> />
<View style={styles.liveBadge}> <View style={styles.liveBadge}>
<LivePulse /> {isLivePulse && <LivePulse />}
<Text style={styles.liveBadgeText}></Text> <Text style={styles.liveBadgeText}></Text>
</View> </View>
<View style={styles.meta}> <View style={styles.meta}>

View File

@@ -28,7 +28,7 @@ export function useVideoList() {
setPages(prev => reset ? [data] : [...prev, data]); setPages(prev => reset ? [data] : [...prev, data]);
if (reset || idx === 0) { if (reset || idx === 0) {
// Take top 2 by online count // Take top 2 by online count
const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 2); const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 10);
setLiveRooms(sorted); setLiveRooms(sorted);
} }
freshIdxRef.current = idx + 1; freshIdxRef.current = idx + 1;

View File

@@ -21,7 +21,7 @@ export type ListRow = NormalRow | BigRow | LiveRow;
export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRow[] { export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRow[] {
const rows: ListRow[] = []; const rows: ListRow[] = [];
let liveInjected = false; let roomIdx = 0;
for (const chunk of pages) { for (const chunk of pages) {
if (chunk.length === 0) continue; if (chunk.length === 0) continue;
@@ -37,26 +37,24 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo
const bigItem = chunk[bigIdx]; const bigItem = chunk[bigIdx];
const rest = chunk.filter((_, i) => i !== bigIdx); const rest = chunk.filter((_, i) => i !== bigIdx);
rows.push({ type: 'big', item: bigItem }); const pairs: (NormalRow | LiveRow)[] = [];
const pairs: NormalRow[] = [];
for (let i = 0; i < rest.length; i += 2) { for (let i = 0; i < rest.length; i += 2) {
pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null }); pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
} }
// Inject LiveRow once, after ~3 pairs in the first chunk // Inject 1 LiveRow per chunk at a deterministic position (seed = first video aid)
if (!liveInjected && liveRooms && liveRooms.length > 0) { if (liveRooms && roomIdx < liveRooms.length && pairs.length > 0) {
const insertAt = Math.min(3, pairs.length); const seed = chunk[0]?.aid ?? 0;
const liveRow: LiveRow = { const insertAt = seed % (pairs.length + 1);
pairs.splice(insertAt, 0, {
type: 'live', type: 'live',
left: liveRooms[0], left: liveRooms[roomIdx],
right: liveRooms[1], });
}; roomIdx++;
pairs.splice(insertAt, 0, liveRow as any);
liveInjected = true;
} }
rows.push(...pairs); rows.push(...pairs);
rows.push({ type: 'big', item: bigItem });
} }
return rows; return rows;
} }