This commit is contained in:
Developer
2026-03-14 11:55:45 +08:00
parent a971a65f96
commit e9b00d026f
6 changed files with 102 additions and 108 deletions

View File

@@ -31,7 +31,7 @@ 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 } 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 +62,7 @@ const LIVE_AREAS = [
export default function HomeScreen() { export default function HomeScreen() {
const router = useRouter(); const router = useRouter();
const { pages, loading, refreshing, load, refresh } = useVideoList(); const { pages, liveRooms, loading, refreshing, load, refresh } = useVideoList();
const { const {
rooms, rooms,
loading: liveLoading, loading: liveLoading,
@@ -77,7 +77,7 @@ export default function HomeScreen() {
const [liveAreaId, setLiveAreaId] = useState(0); const [liveAreaId, setLiveAreaId] = useState(0);
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null); const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages), [pages]); const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const hotListRef = useRef<FlatList>(null); const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(null); const liveListRef = useRef<FlatList>(null);
@@ -186,16 +186,30 @@ export default function HomeScreen() {
<BigVideoCard <BigVideoCard
item={row.item} item={row.item}
isVisible={visibleBigKey === row.item.bvid} isVisible={visibleBigKey === row.item.bvid}
onPress={() => { onPress={() => router.push(`/video/${row.item.bvid}` as any)}
if (row.item.goto === 'live' && row.item.roomid) {
Linking.openURL(`https://live.bilibili.com/${row.item.roomid}`);
} else {
router.push(`/video/${row.item.bvid}` as any);
}
}}
/> />
); );
} }
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>
);
}
const right = row.right; const right = row.right;
return ( return (
<View style={styles.row}> <View style={styles.row}>
@@ -260,6 +274,8 @@ export default function HomeScreen() {
keyExtractor={(row: any) => keyExtractor={(row: any) =>
row.type === "big" row.type === "big"
? `big-${row.item.bvid}` ? `big-${row.item.bvid}`
: row.type === "live"
? `live-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
} }
contentContainerStyle={{ contentContainerStyle={{
@@ -387,9 +403,9 @@ export default function HomeScreen() {
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.headerBtn}> {/* <TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" /> <Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity> </TouchableOpacity> */}
</View> </View>
<Text style={styles.logo}></Text> <Text style={styles.logo}></Text>
</Animated.View> </Animated.View>

View File

@@ -16,7 +16,6 @@ import { buildDashMpdUri } from "../utils/dash";
import { getPlayUrl, getVideoDetail } from "../services/bilibili"; import { getPlayUrl, getVideoDetail } from "../services/bilibili";
import { proxyImageUrl } from "../utils/imageUrl"; import { proxyImageUrl } from "../utils/imageUrl";
import { formatCount, formatDuration } from "../utils/format"; import { formatCount, formatDuration } from "../utils/format";
import { LivePulse } from "./LivePulse";
import type { VideoItem } from "../services/types"; import type { VideoItem } from "../services/types";
const HEADERS = { const HEADERS = {
@@ -75,11 +74,8 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
thumbOpacity.setValue(1); thumbOpacity.setValue(1);
}, [item.bvid]); }, [item.bvid]);
const isLive = item.goto === 'live';
// Fetch play URL when visible for the first time // Fetch play URL when visible for the first time
useEffect(() => { useEffect(() => {
if (isLive) return;
if (!isVisible || videoUrl) return; if (!isVisible || videoUrl) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@@ -220,10 +216,8 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
/> />
</Animated.View> </Animated.View>
{/* Swipe gesture layer (video only) */} {/* Swipe gesture layer */}
{!isLive && ( <View style={StyleSheet.absoluteFill} {...panResponder.panHandlers} />
<View style={StyleSheet.absoluteFill} {...panResponder.panHandlers} />
)}
{/* Seek time label */} {/* Seek time label */}
{seekLabel && ( {seekLabel && (
@@ -232,29 +226,18 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
</View> </View>
)} )}
{/* Live badge */}
{isLive && (
<View style={styles.liveBadge}>
<LivePulse />
<Text style={styles.liveBadgeText}></Text>
</View>
)}
<View style={styles.meta}> <View style={styles.meta}>
<Ionicons name={isLive ? "people" : "play"} size={11} color="#fff" /> <Ionicons name="play" size={11} color="#fff" />
<Text style={styles.metaText}> <Text style={styles.metaText}>
{formatCount(isLive ? (item.online ?? 0) : (item.stat?.view ?? 0))} {formatCount(item.stat?.view ?? 0)}
</Text> </Text>
</View> </View>
{/* Duration badge on thumbnail (video only) */} <View style={styles.durationBadge}>
{!isLive && ( <Text style={styles.durationText}>
<View style={styles.durationBadge}> {formatDuration(item.duration)}
<Text style={styles.durationText}> </Text>
{formatDuration(item.duration)} </View>
</Text>
</View>
)}
{/* Mute toggle — visible only when video is playing */} {/* Mute toggle — visible only when video is playing */}
{videoUrl && !paused && ( {videoUrl && !paused && (
@@ -272,8 +255,8 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
)} )}
</View> </View>
{/* Progress bar between video and info (video only) */} {/* Progress bar between video and info */}
{!isLive && videoUrl && duration > 0 && ( {videoUrl && duration > 0 && (
<View style={styles.progressTrack}> <View style={styles.progressTrack}>
<View <View
style={[ style={[
@@ -312,20 +295,6 @@ const styles = StyleSheet.create({
borderRadius: 6, borderRadius: 6,
overflow: "hidden", overflow: "hidden",
}, },
liveBadge: {
position: "absolute",
top: 8,
left: 8,
backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: 5,
paddingHorizontal: 5,
paddingVertical: 1,
flexDirection: "row",
alignItems: "center",
gap: 2,
zIndex: 2,
},
liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" },
durationBadge: { durationBadge: {
position: "absolute", position: "absolute",
bottom: 4, bottom: 4,

View File

@@ -8,10 +8,10 @@ import {
Dimensions, Dimensions,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LivePulse } from "./LivePulse";
import type { LiveRoom } from "../services/types"; import type { LiveRoom } from "../services/types";
import { formatCount } from "../utils/format"; import { formatCount } from "../utils/format";
import { proxyImageUrl } from "../utils/imageUrl"; import { proxyImageUrl } from "../utils/imageUrl";
import { LivePulse } from "./LivePulse";
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
const CARD_WIDTH = (width - 14) / 2; const CARD_WIDTH = (width - 14) / 2;

View File

@@ -1,9 +1,10 @@
import { useState, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useRef, useMemo } from 'react';
import { getRecommendFeed } from '../services/bilibili'; import { getRecommendFeed, getLiveList } from '../services/bilibili';
import type { VideoItem } from '../services/types'; import type { VideoItem, LiveRoom } from '../services/types';
export function useVideoList() { export function useVideoList() {
const [pages, setPages] = useState<VideoItem[][]>([]); const [pages, setPages] = useState<VideoItem[][]>([]);
const [liveRooms, setLiveRooms] = useState<LiveRoom[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -17,8 +18,19 @@ export function useVideoList() {
const idx = reset ? 0 : freshIdxRef.current; const idx = reset ? 0 : freshIdxRef.current;
setLoading(true); setLoading(true);
try { try {
const data = await getRecommendFeed(idx); const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [
getRecommendFeed(idx),
reset || idx === 0
? getLiveList(1, 0).catch(() => [] as LiveRoom[])
: Promise.resolve([] as LiveRoom[]),
];
const [data, live] = await Promise.all(promises);
setPages(prev => reset ? [data] : [...prev, data]); 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);
setLiveRooms(sorted);
}
freshIdxRef.current = idx + 1; freshIdxRef.current = idx + 1;
} catch (e) { } catch (e) {
console.error('Failed to load videos', e); console.error('Failed to load videos', e);
@@ -36,5 +48,5 @@ export function useVideoList() {
const videos = useMemo(() => pages.flat(), [pages]); const videos = useMemo(() => pages.flat(), [pages]);
return { videos, pages, loading, refreshing, load, refresh }; return { videos, pages, liveRooms, loading, refreshing, load, refresh };
} }

View File

@@ -83,39 +83,13 @@ export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed }); const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed });
const items: any[] = res.data.data?.item ?? []; const items: any[] = res.data.data?.item ?? [];
return items return items
.filter(item => .filter(item => item.goto === 'av' && item.bvid && item.title)
(item.goto === 'av' && item.bvid && item.title) || .map(item => ({
(item.goto === 'live' && item.title), ...item,
) aid: item.id ?? item.aid,
.map(item => { pic: item.pic ?? item.cover,
if (item.goto === 'live') { owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
const roomid = item.roomid ?? item.room_id ?? 0; } as VideoItem));
return {
bvid: `live-${roomid}`,
aid: 0,
title: item.title,
pic: item.pic ?? item.cover ?? '',
owner: item.owner ?? {
mid: item.owner_info?.mid ?? item.uid ?? 0,
name: item.owner_info?.name ?? item.uname ?? '',
face: item.owner_info?.face ?? item.face ?? '',
},
duration: 0,
desc: '',
stat: null,
goto: 'live' as const,
roomid,
online: item.watched_show?.num ?? item.online ?? 0,
area_name: item.area_name ?? '',
} as VideoItem;
}
return {
...item,
aid: item.id ?? item.aid,
pic: item.pic ?? item.cover,
owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
} as VideoItem;
});
} }
export async function getPopularVideos(pn = 1): Promise<VideoItem[]> { export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {

View File

@@ -1,4 +1,4 @@
import type { VideoItem } from '../services/types'; import type { VideoItem, LiveRoom } from '../services/types';
export interface NormalRow { export interface NormalRow {
type: 'pair'; type: 'pair';
@@ -11,29 +11,52 @@ export interface BigRow {
item: VideoItem; item: VideoItem;
} }
export type ListRow = NormalRow | BigRow; export interface LiveRow {
type: 'live';
left: LiveRoom;
right?: LiveRoom;
}
export function toListRows(pages: VideoItem[][]): ListRow[] { export type ListRow = NormalRow | BigRow | LiveRow;
export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRow[] {
const rows: ListRow[] = []; const rows: ListRow[] = [];
let liveInjected = false;
for (const chunk of pages) { for (const chunk of pages) {
if (chunk.length === 0) continue; if (chunk.length === 0) continue;
// Prioritize: first live item becomes BigRow
let bigIdx = chunk.findIndex(item => item.goto === 'live'); // Highest view count becomes BigRow
if (bigIdx === -1) { let bigIdx = 0;
// Fallback: highest view count let maxView = chunk[0].stat?.view ?? 0;
bigIdx = 0; for (let i = 1; i < chunk.length; i++) {
let maxView = chunk[0].stat?.view ?? 0; const v = chunk[i].stat?.view ?? 0;
for (let i = 1; i < chunk.length; i++) { if (v > maxView) { maxView = v; bigIdx = i; }
const v = chunk[i].stat?.view ?? 0;
if (v > maxView) { maxView = v; bigIdx = i; }
}
} }
const bigItem = chunk[bigIdx]; const bigItem = chunk[bigIdx];
const rest = chunk.filter((_, i) => i !== bigIdx); const rest = chunk.filter((_, i) => i !== bigIdx);
for (let i = 0; i < rest.length; i += 2) {
rows.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
}
rows.push({ type: 'big', item: bigItem }); rows.push({ type: 'big', item: bigItem });
const pairs: NormalRow[] = [];
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 = {
type: 'live',
left: liveRooms[0],
right: liveRooms[1],
};
pairs.splice(insertAt, 0, liveRow as any);
liveInjected = true;
}
rows.push(...pairs);
} }
return rows; return rows;
} }