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,22 +205,16 @@ 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={() =>
Linking.openURL(`https://live.bilibili.com/${row.left.roomid}`)
}
/> />
</View> </View>
{row.right && (
<View style={styles.rightCol}>
<LiveCard
item={row.right}
onPress={() => Linking.openURL(`https://live.bilibili.com/${row.right!.roomid}`)}
/>
</View>
)}
</View>
); );
} }
const right = row.right; const right = row.right;
@@ -263,10 +270,23 @@ 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"]}>
{/* 滑动切换容器 */}
<View style={styles.tabSlideOuter}>
<Animated.View
style={[
styles.tabSlideRow,
{ width: SCREEN_W * 2, transform: [{ translateX: tabSlideX }] },
]}
>
{/* 热门列表 */} {/* 热门列表 */}
{activeTab === "hot" && ( <View style={{ width: SCREEN_W, height: "100%" }}>
<Animated.FlatList <Animated.FlatList
ref={hotListRef as any} ref={hotListRef as any}
style={styles.listContainer} style={styles.listContainer}
@@ -303,10 +323,10 @@ export default function HomeScreen() {
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
/> />
)} </View>
{/* 直播列表 */} {/* 直播列表 */}
{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}
@@ -368,7 +388,9 @@ export default function HomeScreen() {
onScroll={onLiveScroll} onScroll={onLiveScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
/> />
)} </View>
</Animated.View>
</View>
{/* 绝对定位导航栏 */} {/* 绝对定位导航栏 */}
<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",
@@ -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,9 +257,10 @@ 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 <View
style={[ style={[
styles.progressLayer, styles.progressLayer,
@@ -270,8 +273,9 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" }, { 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;
} }