mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
1
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user