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:
285
app/index.tsx
285
app/index.tsx
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user