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,
ScrollView,
Linking,
useWindowDimensions,
} from "react-native";
import {
SafeAreaView,
@@ -31,7 +32,12 @@ 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, type LiveRow } from "../utils/videoRows";
import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard";
import type { LiveRoom } from "../services/types";
@@ -62,7 +68,8 @@ const LIVE_AREAS = [
export default function HomeScreen() {
const router = useRouter();
const { pages, liveRooms, loading, refreshing, load, refresh } = useVideoList();
const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
const {
rooms,
loading: liveLoading,
@@ -76,8 +83,10 @@ export default function HomeScreen() {
const [activeTab, setActiveTab] = useState<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0);
const { width: SCREEN_W } = useWindowDimensions();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const tabAnim = useRef(new Animated.Value(0)).current;
const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(null);
@@ -134,10 +143,9 @@ export default function HomeScreen() {
const onLiveScroll = useMemo(
() =>
Animated.event(
[{ nativeEvent: { contentOffset: { y: liveScrollY } } }],
{ useNativeDriver: true },
),
Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
useNativeDriver: true,
}),
[],
);
@@ -156,6 +164,11 @@ export default function HomeScreen() {
}
// 切换 tab
setActiveTab(key);
Animated.timing(tabAnim, {
toValue: key === "hot" ? 0 : 1,
duration: 250,
useNativeDriver: true,
}).start();
if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId);
}
@@ -192,21 +205,15 @@ export default function HomeScreen() {
}
if (row.type === "live") {
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
item={row.left}
onPress={() => Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)}
/>
</View>
{row.right && (
<View style={styles.rightCol}>
<LiveCard
item={row.right}
onPress={() => Linking.openURL(`https://live.bilibili.com/${row.right!.roomid}`)}
/>
</View>
)}
<View style={styles.liveRow}>
<LiveCard
isLivePulse
item={row.left}
fullWidth
onPress={() =>
Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)
}
/>
</View>
);
}
@@ -237,11 +244,11 @@ export default function HomeScreen() {
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard item={item.left} />
<LiveCard item={item.left} />
</View>
{item.right && (
<View style={styles.rightCol}>
<LiveCard item={item.right} />
<LiveCard item={item.right} />
</View>
)}
</View>
@@ -263,112 +270,127 @@ export default function HomeScreen() {
const currentHeaderOpacity =
activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
const tabSlideX = tabAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, -SCREEN_W],
});
return (
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
{/* 热门列表 */}
{activeTab === "hot" && (
<Animated.FlatList
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
keyExtractor={(row: any) =>
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
? `live-${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}
{/* 滑动切换容器 */}
<View style={styles.tabSlideOuter}>
<Animated.View
style={[
styles.tabSlideRow,
{ width: SCREEN_W * 2, transform: [{ translateX: tabSlideX }] },
]}
>
{/* 热门列表 */}
<View style={{ width: SCREEN_W, height: "100%" }}>
<Animated.FlatList
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
keyExtractor={(row: any) =>
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
? `live-${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}
/>
}
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>
{/* 直播列表 */}
{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}
{/* 直播列表 */}
<View style={{ width: SCREEN_W, height: "100%" }}>
<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}
>
<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}
{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}
/>
}
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>
</Animated.View>
</View>
{/* 绝对定位导航栏 */}
<Animated.View
@@ -439,6 +461,8 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: "#f4f4f4" },
tabSlideOuter: { flex: 1, overflow: "hidden" },
tabSlideRow: { flexDirection: "row", height: "100%" },
listContainer: { flex: 1 },
navBar: {
position: "absolute",
@@ -469,8 +493,8 @@ const styles = StyleSheet.create({
color: "#00AEEC",
letterSpacing: -0.5,
},
headerRight: { flexDirection: "row", gap: 8,alignItems: "center" },
headerBtn: {paddingLeft:0 },
headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: { paddingLeft: 0 },
userAvatar: {
width: 35,
height: 35,
@@ -514,7 +538,16 @@ const styles = StyleSheet.create({
},
leftCol: { marginLeft: 4, marginRight: 2 },
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" },
areaTabRow: {
marginBottom: 6,

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export function useVideoList() {
setPages(prev => reset ? [data] : [...prev, data]);
if (reset || idx === 0) {
// 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);
}
freshIdxRef.current = idx + 1;

View File

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