mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
直播卡片
This commit is contained in:
@@ -17,7 +17,17 @@ export default function RootLayout() {
|
|||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen
|
||||||
|
name="video"
|
||||||
|
options={{
|
||||||
|
animation: "slide_from_right",
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: "horizontal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
366
app/index.tsx
366
app/index.tsx
@@ -15,6 +15,9 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ViewToken,
|
ViewToken,
|
||||||
|
FlatList,
|
||||||
|
ScrollView,
|
||||||
|
Linking,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
@@ -23,11 +26,14 @@ import {
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { VideoCard } from "../components/VideoCard";
|
import { VideoCard } from "../components/VideoCard";
|
||||||
|
import { LiveCard } from "../components/LiveCard";
|
||||||
import { LoginModal } from "../components/LoginModal";
|
import { LoginModal } from "../components/LoginModal";
|
||||||
import { useVideoList } from "../hooks/useVideoList";
|
import { useVideoList } from "../hooks/useVideoList";
|
||||||
|
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 } from "../utils/videoRows";
|
||||||
import { BigVideoCard } from "../components/BigVideoCard";
|
import { BigVideoCard } from "../components/BigVideoCard";
|
||||||
|
import type { LiveRoom } from "../services/types";
|
||||||
|
|
||||||
const HEADER_H = 44;
|
const HEADER_H = 44;
|
||||||
const TAB_H = 38;
|
const TAB_H = 38;
|
||||||
@@ -35,15 +41,46 @@ const NAV_H = HEADER_H + TAB_H;
|
|||||||
|
|
||||||
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
|
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
|
||||||
|
|
||||||
|
type TabKey = "hot" | "live";
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: "hot", label: "热门" },
|
||||||
|
{ key: "live", label: "直播" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LIVE_AREAS = [
|
||||||
|
{ id: 0, name: "推荐" },
|
||||||
|
{ id: 2, name: "网游" },
|
||||||
|
{ id: 3, name: "手游" },
|
||||||
|
{ id: 6, name: "单机游戏" },
|
||||||
|
{ id: 1, name: "娱乐" },
|
||||||
|
{ id: 9, name: "虚拟主播" },
|
||||||
|
{ id: 10, name: "生活" },
|
||||||
|
{ id: 11, name: "知识" },
|
||||||
|
{ id: 13, name: "赛事" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { videos, loading, refreshing, load, refresh } = useVideoList();
|
const { pages, loading, refreshing, load, refresh } = useVideoList();
|
||||||
|
const {
|
||||||
|
rooms,
|
||||||
|
loading: liveLoading,
|
||||||
|
refreshing: liveRefreshing,
|
||||||
|
load: liveLoad,
|
||||||
|
refresh: liveRefresh,
|
||||||
|
} = useLiveList();
|
||||||
const { isLoggedIn, face, logout } = useAuthStore();
|
const { isLoggedIn, face, logout } = useAuthStore();
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>("hot");
|
||||||
|
const [liveAreaId, setLiveAreaId] = useState(0);
|
||||||
|
|
||||||
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
||||||
const rows = useMemo(() => toListRows(videos), [videos]);
|
const rows = useMemo(() => toListRows(pages), [pages]);
|
||||||
|
|
||||||
|
const hotListRef = useRef<FlatList>(null);
|
||||||
|
const liveListRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
const onViewableItemsChangedRef = useRef(
|
const onViewableItemsChangedRef = useRef(
|
||||||
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||||
@@ -56,9 +93,6 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
const scrollY = useRef(new Animated.Value(0)).current;
|
const scrollY = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
// 阻尼限制
|
|
||||||
const diffClamp = Animated.diffClamp(scrollY, 0, HEADER_H);
|
|
||||||
|
|
||||||
const headerTranslate = scrollY.interpolate({
|
const headerTranslate = scrollY.interpolate({
|
||||||
inputRange: [0, HEADER_H],
|
inputRange: [0, HEADER_H],
|
||||||
outputRange: [0, -HEADER_H],
|
outputRange: [0, -HEADER_H],
|
||||||
@@ -71,6 +105,21 @@ export default function HomeScreen() {
|
|||||||
extrapolate: "clamp",
|
extrapolate: "clamp",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 直播列表也共用同一个 scrollY
|
||||||
|
const liveScrollY = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const liveHeaderTranslate = liveScrollY.interpolate({
|
||||||
|
inputRange: [0, HEADER_H],
|
||||||
|
outputRange: [0, -HEADER_H],
|
||||||
|
extrapolate: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveHeaderOpacity = liveScrollY.interpolate({
|
||||||
|
inputRange: [0, HEADER_H * 0.2],
|
||||||
|
outputRange: [1, 0],
|
||||||
|
extrapolate: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -83,6 +132,53 @@ export default function HomeScreen() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onLiveScroll = useMemo(
|
||||||
|
() =>
|
||||||
|
Animated.event(
|
||||||
|
[{ nativeEvent: { contentOffset: { y: liveScrollY } } }],
|
||||||
|
{ useNativeDriver: true },
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabPress = useCallback(
|
||||||
|
(key: TabKey) => {
|
||||||
|
if (key === activeTab) {
|
||||||
|
// 点击已激活的 tab:滚动到顶部并刷新
|
||||||
|
if (key === "hot") {
|
||||||
|
hotListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
refresh();
|
||||||
|
} else {
|
||||||
|
liveListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
liveRefresh(liveAreaId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 切换 tab
|
||||||
|
setActiveTab(key);
|
||||||
|
if (key === "live" && rooms.length === 0) {
|
||||||
|
liveLoad(true, liveAreaId);
|
||||||
|
}
|
||||||
|
// 重置 header 动画
|
||||||
|
if (key === "hot") {
|
||||||
|
scrollY.setValue(0);
|
||||||
|
} else {
|
||||||
|
liveScrollY.setValue(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTab, rooms.length, liveAreaId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLiveAreaPress = useCallback(
|
||||||
|
(areaId: number) => {
|
||||||
|
if (areaId === liveAreaId) return;
|
||||||
|
setLiveAreaId(areaId);
|
||||||
|
liveListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
liveLoad(true, areaId);
|
||||||
|
},
|
||||||
|
[liveAreaId, liveLoad],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item: row }: { item: ListRow }) => {
|
({ item: row }: { item: ListRow }) => {
|
||||||
if (row.type === "big") {
|
if (row.type === "big") {
|
||||||
@@ -90,11 +186,16 @@ export default function HomeScreen() {
|
|||||||
<BigVideoCard
|
<BigVideoCard
|
||||||
item={row.item}
|
item={row.item}
|
||||||
isVisible={visibleBigKey === row.item.bvid}
|
isVisible={visibleBigKey === row.item.bvid}
|
||||||
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
|
onPress={() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Normal pair row
|
|
||||||
const right = row.right;
|
const right = row.right;
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
@@ -118,48 +219,148 @@ export default function HomeScreen() {
|
|||||||
[visibleBigKey],
|
[visibleBigKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderLiveItem = useCallback(
|
||||||
|
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.leftCol}>
|
||||||
|
<LiveCard item={item.left} />
|
||||||
|
</View>
|
||||||
|
{item.right && (
|
||||||
|
<View style={styles.rightCol}>
|
||||||
|
<LiveCard item={item.right} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将直播列表分成两列的行
|
||||||
|
const liveRows = useMemo(() => {
|
||||||
|
const result: { left: LiveRoom; right?: LiveRoom }[] = [];
|
||||||
|
for (let i = 0; i < rooms.length; i += 2) {
|
||||||
|
result.push({ left: rooms[i], right: rooms[i + 1] });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [rooms]);
|
||||||
|
|
||||||
|
const currentHeaderTranslate =
|
||||||
|
activeTab === "hot" ? headerTranslate : liveHeaderTranslate;
|
||||||
|
const currentHeaderOpacity =
|
||||||
|
activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
|
<SafeAreaView style={styles.safe} edges={["left", "right"]}>
|
||||||
<Animated.FlatList
|
{/* 热门列表 */}
|
||||||
style={styles.listContainer}
|
{activeTab === "hot" && (
|
||||||
data={rows}
|
<Animated.FlatList
|
||||||
keyExtractor={(row: any) =>
|
ref={hotListRef as any}
|
||||||
row.type === "big"
|
style={styles.listContainer}
|
||||||
? `big-${row.item.bvid}`
|
data={rows}
|
||||||
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
|
keyExtractor={(row: any) =>
|
||||||
}
|
row.type === "big"
|
||||||
contentContainerStyle={{
|
? `big-${row.item.bvid}`
|
||||||
paddingTop: insets.top + NAV_H + 6,
|
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
|
||||||
paddingBottom: insets.bottom + 16,
|
}
|
||||||
}}
|
contentContainerStyle={{
|
||||||
renderItem={renderItem}
|
paddingTop: insets.top + NAV_H + 6,
|
||||||
refreshControl={
|
paddingBottom: insets.bottom + 16,
|
||||||
<RefreshControl
|
}}
|
||||||
refreshing={refreshing}
|
renderItem={renderItem}
|
||||||
onRefresh={refresh}
|
refreshControl={
|
||||||
progressViewOffset={insets.top + NAV_H}
|
<RefreshControl
|
||||||
/>
|
refreshing={refreshing}
|
||||||
}
|
onRefresh={refresh}
|
||||||
onEndReached={() => load()}
|
progressViewOffset={insets.top + NAV_H}
|
||||||
onEndReachedThreshold={0.5}
|
/>
|
||||||
extraData={visibleBigKey}
|
}
|
||||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
onEndReached={() => load()}
|
||||||
onViewableItemsChanged={onViewableItemsChangedRef}
|
onEndReachedThreshold={0.5}
|
||||||
ListFooterComponent={
|
extraData={visibleBigKey}
|
||||||
<View style={styles.footer}>
|
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||||
{loading && <ActivityIndicator color="#00AEEC" />}
|
onViewableItemsChanged={onViewableItemsChangedRef}
|
||||||
</View>
|
ListFooterComponent={
|
||||||
}
|
<View style={styles.footer}>
|
||||||
onScroll={onScroll}
|
{loading && <ActivityIndicator color="#00AEEC" />}
|
||||||
scrollEventThrottle={16}
|
</View>
|
||||||
/>
|
}
|
||||||
{/* 绝对定位导航栏:paddingTop 手动适配刘海/状态栏 */}
|
onScroll={onScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 直播列表 */}
|
||||||
|
{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}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 绝对定位导航栏 */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.navBar,
|
styles.navBar,
|
||||||
{
|
{
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
transform: [{ translateY: headerTranslate }],
|
transform: [{ translateY: currentHeaderTranslate }],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -167,7 +368,7 @@ export default function HomeScreen() {
|
|||||||
style={[
|
style={[
|
||||||
styles.header,
|
styles.header,
|
||||||
{
|
{
|
||||||
opacity: headerOpacity,
|
opacity: currentHeaderOpacity,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -194,8 +395,24 @@ export default function HomeScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
<Text style={styles.tabActive}>热门</Text>
|
{TABS.map((tab) => (
|
||||||
<View style={styles.tabUnderline} />
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
style={styles.tabItem}
|
||||||
|
onPress={() => handleTabPress(tab.key)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabText,
|
||||||
|
activeTab === tab.key && styles.tabTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
{activeTab === tab.key && <View style={styles.tabUnderline} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -215,9 +432,7 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
// 安卓投影
|
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
// iOS 投影
|
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.06,
|
||||||
@@ -238,12 +453,12 @@ const styles = StyleSheet.create({
|
|||||||
color: "#00AEEC",
|
color: "#00AEEC",
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
headerRight: { flexDirection: "row", gap: 8 },
|
headerRight: { flexDirection: "row", gap: 8,alignItems: "center" },
|
||||||
headerBtn: { padding: 6 },
|
headerBtn: {paddingLeft:0 },
|
||||||
userAvatar: {
|
userAvatar: {
|
||||||
width: 28,
|
width: 35,
|
||||||
height: 28,
|
height: 35,
|
||||||
borderRadius: 14,
|
borderRadius: 50,
|
||||||
backgroundColor: "#eee",
|
backgroundColor: "#eee",
|
||||||
},
|
},
|
||||||
tabRow: {
|
tabRow: {
|
||||||
@@ -252,12 +467,25 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
tabItem: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: TAB_H,
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "#999",
|
||||||
|
},
|
||||||
|
tabTextActive: {
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#00AEEC",
|
||||||
},
|
},
|
||||||
tabActive: { fontSize: 15, fontWeight: "700", color: "#00AEEC" },
|
|
||||||
tabUnderline: {
|
tabUnderline: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 4,
|
bottom: 4,
|
||||||
left: 20,
|
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 3,
|
height: 3,
|
||||||
backgroundColor: "#00AEEC",
|
backgroundColor: "#00AEEC",
|
||||||
@@ -270,5 +498,33 @@ 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" },
|
footer: { height: 48, alignItems: "center", justifyContent: "center", flexDirection: "row", gap: 6 },
|
||||||
|
footerText: { fontSize: 12, color: "#999" },
|
||||||
|
areaTabRow: {
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
areaTabContent: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
areaTab: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
areaTabActive: {
|
||||||
|
backgroundColor: "#00AEEC",
|
||||||
|
},
|
||||||
|
areaTabText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#333",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
areaTabTextActive: {
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// components/BigVideoCard.tsx
|
// components/BigVideoCard.tsx
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -8,13 +8,15 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
Animated,
|
Animated,
|
||||||
|
PanResponder,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Video from "react-native-video";
|
import Video, { VideoRef } from "react-native-video";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { buildDashMpdUri } from "../utils/dash";
|
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 = {
|
||||||
@@ -23,6 +25,16 @@ const HEADERS = {
|
|||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BAR_H = 3;
|
||||||
|
// Minimum horizontal distance (px) before treating the gesture as a seek
|
||||||
|
const SWIPE_THRESHOLD = 8;
|
||||||
|
// Full swipe across the screen = seek this many seconds
|
||||||
|
const SWIPE_SECONDS = 90;
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number) {
|
||||||
|
return Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: VideoItem;
|
item: VideoItem;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -40,22 +52,38 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
const [muted, setMuted] = useState(true);
|
const [muted, setMuted] = useState(true);
|
||||||
const thumbOpacity = useRef(new Animated.Value(1)).current;
|
const thumbOpacity = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const videoRef = useRef<VideoRef>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [buffered, setBuffered] = useState(0);
|
||||||
|
|
||||||
|
// Refs for PanResponder (avoid stale closures)
|
||||||
|
const currentTimeRef = useRef(0);
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
const seekingRef = useRef(false);
|
||||||
|
const [seekLabel, setSeekLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
// Reset video state when the item changes
|
// Reset video state when the item changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVideoUrl(undefined);
|
setVideoUrl(undefined);
|
||||||
setIsDash(false);
|
setIsDash(false);
|
||||||
setPaused(true);
|
setPaused(true);
|
||||||
setMuted(true);
|
setMuted(true);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setBuffered(0);
|
||||||
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 () => {
|
||||||
try {
|
try {
|
||||||
// cid may be missing from feed items; fetch detail if needed
|
|
||||||
let cid = item.cid;
|
let cid = item.cid;
|
||||||
if (!cid) {
|
if (!cid) {
|
||||||
const detail = await getVideoDetail(item.bvid);
|
const detail = await getVideoDetail(item.bvid);
|
||||||
@@ -82,7 +110,6 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
// videoUrl intentionally excluded — re-fetch guard prevents redundant fetches after URL is set
|
|
||||||
}, [isVisible, item.bvid]);
|
}, [isVisible, item.bvid]);
|
||||||
|
|
||||||
// Pause/resume when visibility changes
|
// Pause/resume when visibility changes
|
||||||
@@ -91,7 +118,6 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
setPaused(!isVisible);
|
setPaused(!isVisible);
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
setMuted(true);
|
setMuted(true);
|
||||||
// Restore thumbnail when leaving viewport
|
|
||||||
Animated.timing(thumbOpacity, {
|
Animated.timing(thumbOpacity, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 150,
|
duration: 150,
|
||||||
@@ -110,6 +136,50 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
}).start();
|
}).start();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep refs in sync
|
||||||
|
useEffect(() => {
|
||||||
|
currentTimeRef.current = currentTime;
|
||||||
|
}, [currentTime]);
|
||||||
|
useEffect(() => {
|
||||||
|
durationRef.current = duration;
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
// Horizontal swipe to seek
|
||||||
|
const swipeStartTime = useRef(0);
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onMoveShouldSetPanResponder: (_, gs) =>
|
||||||
|
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
|
||||||
|
onPanResponderGrant: () => {
|
||||||
|
seekingRef.current = true;
|
||||||
|
swipeStartTime.current = currentTimeRef.current;
|
||||||
|
},
|
||||||
|
onPanResponderMove: (_, gs) => {
|
||||||
|
if (durationRef.current <= 0) return;
|
||||||
|
const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS;
|
||||||
|
const target = clamp(swipeStartTime.current + delta, 0, durationRef.current);
|
||||||
|
setSeekLabel(formatDuration(Math.floor(target)));
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gs) => {
|
||||||
|
if (durationRef.current > 0) {
|
||||||
|
const delta = (gs.dx / SCREEN_W) * SWIPE_SECONDS;
|
||||||
|
const target = clamp(swipeStartTime.current + delta, 0, durationRef.current);
|
||||||
|
videoRef.current?.seek(target);
|
||||||
|
setCurrentTime(target);
|
||||||
|
}
|
||||||
|
seekingRef.current = false;
|
||||||
|
setSeekLabel(null);
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
seekingRef.current = false;
|
||||||
|
setSeekLabel(null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||||
|
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
||||||
{/* Media area */}
|
{/* Media area */}
|
||||||
@@ -117,6 +187,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
{/* Video player — rendered first so it sits behind the thumbnail */}
|
{/* Video player — rendered first so it sits behind the thumbnail */}
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
<Video
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
source={
|
source={
|
||||||
isDash
|
isDash
|
||||||
? { uri: videoUrl, type: "mpd", headers: HEADERS }
|
? { uri: videoUrl, type: "mpd", headers: HEADERS }
|
||||||
@@ -129,6 +200,11 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
repeat
|
repeat
|
||||||
controls={false}
|
controls={false}
|
||||||
onReadyForDisplay={handleVideoReady}
|
onReadyForDisplay={handleVideoReady}
|
||||||
|
onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
|
||||||
|
if (!seekingRef.current) setCurrentTime(ct);
|
||||||
|
if (dur > 0) setDuration(dur);
|
||||||
|
setBuffered(buf);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -144,19 +220,41 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Swipe gesture layer (video only) */}
|
||||||
|
{!isLive && (
|
||||||
|
<View style={StyleSheet.absoluteFill} {...panResponder.panHandlers} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seek time label */}
|
||||||
|
{seekLabel && (
|
||||||
|
<View style={styles.seekBadge}>
|
||||||
|
<Text style={styles.seekText}>{seekLabel}</Text>
|
||||||
|
</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="play" size={11} color="#fff" />
|
<Ionicons name={isLive ? "people" : "play"} size={11} color="#fff" />
|
||||||
<Text style={styles.metaText}>
|
<Text style={styles.metaText}>
|
||||||
{formatCount(item.stat?.view ?? 0)}
|
{formatCount(isLive ? (item.online ?? 0) : (item.stat?.view ?? 0))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Duration badge on thumbnail */}
|
{/* 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 && (
|
||||||
@@ -174,6 +272,24 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar between video and info (video only) */}
|
||||||
|
{!isLive && 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
<Text style={styles.title} numberOfLines={2}>
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
@@ -196,6 +312,20 @@ 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,
|
||||||
@@ -219,6 +349,28 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
},
|
},
|
||||||
|
seekBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "40%",
|
||||||
|
alignSelf: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
zIndex: 4,
|
||||||
|
},
|
||||||
|
seekText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||||
|
progressTrack: {
|
||||||
|
height: BAR_H,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.08)",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
progressLayer: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
height: BAR_H,
|
||||||
|
},
|
||||||
info: { padding: 8 },
|
info: { padding: 8 },
|
||||||
title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
|
title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
135
components/LiveCard.tsx
Normal file
135
components/LiveCard.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Dimensions,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { LiveRoom } from "../services/types";
|
||||||
|
import { formatCount } from "../utils/format";
|
||||||
|
import { proxyImageUrl } from "../utils/imageUrl";
|
||||||
|
import { LivePulse } from "./LivePulse";
|
||||||
|
|
||||||
|
const { width } = Dimensions.get("window");
|
||||||
|
const CARD_WIDTH = (width - 14) / 2;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: LiveRoom;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveCard({ item, onPress }: Props) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.card}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<View style={styles.thumbContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: proxyImageUrl(item.cover) }}
|
||||||
|
style={styles.thumb}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.liveBadge}>
|
||||||
|
<LivePulse />
|
||||||
|
<Text style={styles.liveBadgeText}>直播中</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<Ionicons name="people" size={11} color="#fff" />
|
||||||
|
<Text style={styles.metaText}>{formatCount(item.online)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.areaBadge}>
|
||||||
|
<Text style={styles.areaText}>{item.area_name}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.ownerRow}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: proxyImageUrl(item.face) }}
|
||||||
|
style={styles.avatar}
|
||||||
|
/>
|
||||||
|
<Text style={styles.owner} numberOfLines={1}>
|
||||||
|
{item.uname}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
marginBottom: 6,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
thumbContainer: { position: "relative" },
|
||||||
|
thumb: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_WIDTH * 0.5625,
|
||||||
|
backgroundColor: "#ddd",
|
||||||
|
},
|
||||||
|
liveBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
left: 4,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
borderRadius: 5,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" },
|
||||||
|
meta: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 4,
|
||||||
|
left: 4,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
metaText: { fontSize: 10, color: "#fff" },
|
||||||
|
areaBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
borderRadius: 5,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
areaText: { color: "#fff", fontSize: 10 },
|
||||||
|
info: { padding: 6 },
|
||||||
|
title: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#212121",
|
||||||
|
height: 33,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
ownerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#eee",
|
||||||
|
},
|
||||||
|
owner: { fontSize: 11, color: "#999", flex: 1 },
|
||||||
|
});
|
||||||
64
components/LivePulse.tsx
Normal file
64
components/LivePulse.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { View, StyleSheet, Animated } from "react-native";
|
||||||
|
|
||||||
|
const BAR_COUNT = 3;
|
||||||
|
const BAR_HEIGHT = 8;
|
||||||
|
|
||||||
|
export function LivePulse() {
|
||||||
|
const anims = useRef(
|
||||||
|
Array.from({ length: BAR_COUNT }, () => new Animated.Value(0.3)),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animations = anims.map((anim, i) =>
|
||||||
|
Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.delay(i * 120),
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: 0.3,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
animations.forEach((a) => a.start());
|
||||||
|
return () => animations.forEach((a) => a.stop());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={pulseStyles.container}>
|
||||||
|
{anims.map((anim, i) => (
|
||||||
|
<Animated.View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
pulseStyles.bar,
|
||||||
|
{
|
||||||
|
transform: [{ scaleY: anim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pulseStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
height: BAR_HEIGHT,
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
width: 2,
|
||||||
|
height: BAR_HEIGHT,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
59
hooks/useLiveList.ts
Normal file
59
hooks/useLiveList.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { getLiveList } from '../services/bilibili';
|
||||||
|
import type { LiveRoom } from '../services/types';
|
||||||
|
|
||||||
|
export function useLiveList() {
|
||||||
|
const [rooms, setRooms] = useState<LiveRoom[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const pendingRef = useRef(false);
|
||||||
|
const pageRef = useRef(1);
|
||||||
|
const areaIdRef = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(async (reset = false, parentAreaId?: number) => {
|
||||||
|
if (loadingRef.current) {
|
||||||
|
if (!reset) pendingRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingRef.current = true;
|
||||||
|
pendingRef.current = false;
|
||||||
|
|
||||||
|
if (parentAreaId !== undefined) {
|
||||||
|
areaIdRef.current = parentAreaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
pageRef.current = 1;
|
||||||
|
setRooms([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pageRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getLiveList(page, areaIdRef.current);
|
||||||
|
setRooms(prev => reset ? data : [...prev, ...data]);
|
||||||
|
pageRef.current = page + 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load live rooms', e);
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setRefreshing(false);
|
||||||
|
|
||||||
|
if (pendingRef.current) {
|
||||||
|
pendingRef.current = false;
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback((parentAreaId?: number) => {
|
||||||
|
setRefreshing(true);
|
||||||
|
load(true, parentAreaId);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { rooms, loading, refreshing, load, refresh };
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { getRecommendFeed } from '../services/bilibili';
|
import { getRecommendFeed } from '../services/bilibili';
|
||||||
import type { VideoItem } from '../services/types';
|
import type { VideoItem } from '../services/types';
|
||||||
|
|
||||||
export function useVideoList() {
|
export function useVideoList() {
|
||||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
const [pages, setPages] = useState<VideoItem[][]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function useVideoList() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getRecommendFeed(idx);
|
const data = await getRecommendFeed(idx);
|
||||||
setVideos(prev => reset ? data : [...prev, ...data]);
|
setPages(prev => reset ? [data] : [...prev, data]);
|
||||||
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);
|
||||||
@@ -34,5 +34,7 @@ export function useVideoList() {
|
|||||||
load(true);
|
load(true);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
return { videos, loading, refreshing, load, refresh };
|
const videos = useMemo(() => pages.flat(), [pages]);
|
||||||
|
|
||||||
|
return { videos, pages, loading, refreshing, load, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import pako from 'pako';
|
import pako from 'pako';
|
||||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem } from './types';
|
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom } from './types';
|
||||||
import { signWbi } from '../utils/wbi';
|
import { signWbi } from '../utils/wbi';
|
||||||
import { parseDanmakuXml } from '../utils/danmaku';
|
import { parseDanmakuXml } from '../utils/danmaku';
|
||||||
|
|
||||||
@@ -83,12 +83,39 @@ 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
|
||||||
.map(item => ({
|
.filter(item =>
|
||||||
...item,
|
(item.goto === 'av' && item.bvid && item.title) ||
|
||||||
aid: item.id ?? item.aid,
|
(item.goto === 'live' && item.title),
|
||||||
pic: item.pic ?? item.cover,
|
)
|
||||||
owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
|
.map(item => {
|
||||||
})) as VideoItem[];
|
if (item.goto === 'live') {
|
||||||
|
const roomid = item.roomid ?? item.room_id ?? 0;
|
||||||
|
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[]> {
|
||||||
@@ -166,6 +193,52 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const LIVE_BASE = isWeb ? 'http://localhost:3001/bilibili-live' : 'https://api.live.bilibili.com';
|
||||||
|
|
||||||
|
export async function getLiveList(page = 1, parentAreaId = 0): Promise<LiveRoom[]> {
|
||||||
|
if (parentAreaId === 0) {
|
||||||
|
// 推荐:使用原有接口
|
||||||
|
const res = await api.get(`${LIVE_BASE}/xlive/web-interface/v1/webMain/getMoreRecList`, {
|
||||||
|
params: { platform: 'web', page, page_size: 20 },
|
||||||
|
});
|
||||||
|
const list: any[] = res.data.data?.recommend_room_list ?? [];
|
||||||
|
return list.map(item => ({
|
||||||
|
roomid: item.roomid,
|
||||||
|
uid: item.uid,
|
||||||
|
title: item.title,
|
||||||
|
uname: item.uname,
|
||||||
|
face: item.face,
|
||||||
|
cover: item.cover ?? item.user_cover ?? item.keyframe,
|
||||||
|
online: item.online,
|
||||||
|
area_name: item.area_v2_name ?? '',
|
||||||
|
parent_area_name: item.area_v2_parent_name ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 分区筛选:使用 getRoomList 接口
|
||||||
|
const res = await api.get(`${LIVE_BASE}/room/v1/area/getRoomList`, {
|
||||||
|
params: {
|
||||||
|
parent_area_id: parentAreaId,
|
||||||
|
area_id: 0,
|
||||||
|
page,
|
||||||
|
page_size: 20,
|
||||||
|
sort_type: 'online',
|
||||||
|
platform: 'web',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const list: any[] = res.data.data ?? [];
|
||||||
|
return list.map(item => ({
|
||||||
|
roomid: item.roomid,
|
||||||
|
uid: item.uid,
|
||||||
|
title: item.title,
|
||||||
|
uname: item.uname,
|
||||||
|
face: item.face,
|
||||||
|
cover: item.cover ?? item.user_cover ?? item.keyframe,
|
||||||
|
online: item.online,
|
||||||
|
area_name: item.area_v2_name ?? item.areaName ?? '',
|
||||||
|
parent_area_name: item.area_v2_parent_name ?? item.parentAreaName ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||||
try {
|
try {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface VideoItem {
|
|||||||
desc: string;
|
desc: string;
|
||||||
cid?: number;
|
cid?: number;
|
||||||
pages?: Array<{ cid: number; part: string }>;
|
pages?: Array<{ cid: number; part: string }>;
|
||||||
|
goto?: 'av' | 'live';
|
||||||
|
roomid?: number;
|
||||||
|
online?: number;
|
||||||
|
area_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
@@ -98,3 +102,15 @@ export interface DanmakuItem {
|
|||||||
color: number; // 0xRRGGBB 十进制整数
|
color: number; // 0xRRGGBB 十进制整数
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LiveRoom {
|
||||||
|
roomid: number;
|
||||||
|
uid: number;
|
||||||
|
title: string;
|
||||||
|
uname: string;
|
||||||
|
face: string;
|
||||||
|
cover: string;
|
||||||
|
online: number;
|
||||||
|
area_name: string;
|
||||||
|
parent_area_name: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,26 +13,20 @@ export interface BigRow {
|
|||||||
|
|
||||||
export type ListRow = NormalRow | BigRow;
|
export type ListRow = NormalRow | BigRow;
|
||||||
|
|
||||||
const PAGE = 21; // matches API page size
|
export function toListRows(pages: VideoItem[][]): ListRow[] {
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform a flat VideoItem array into display rows.
|
|
||||||
* Videos are chunked by page size (20). The last item of each chunk
|
|
||||||
* becomes a full-width BigRow so BigVideoCards stay at stable positions
|
|
||||||
* even as more pages are loaded.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function toListRows(videos: VideoItem[]): ListRow[] {
|
|
||||||
if (videos.length === 0) return [];
|
|
||||||
const rows: ListRow[] = [];
|
const rows: ListRow[] = [];
|
||||||
for (let start = 0; start < videos.length; start += PAGE) {
|
for (const chunk of pages) {
|
||||||
const chunk = videos.slice(start, start + PAGE);
|
if (chunk.length === 0) continue;
|
||||||
// Pick the video with the highest view count as the BigRow
|
// Prioritize: first live item becomes BigRow
|
||||||
let bigIdx = 0;
|
let bigIdx = chunk.findIndex(item => item.goto === 'live');
|
||||||
let maxView = chunk[0].stat?.view ?? 0;
|
if (bigIdx === -1) {
|
||||||
for (let i = 1; i < chunk.length; i++) {
|
// Fallback: highest view count
|
||||||
const v = chunk[i].stat?.view ?? 0;
|
bigIdx = 0;
|
||||||
if (v > maxView) { maxView = v; bigIdx = i; }
|
let maxView = chunk[0].stat?.view ?? 0;
|
||||||
|
for (let i = 1; i < chunk.length; 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);
|
||||||
@@ -41,6 +35,5 @@ export function toListRows(videos: VideoItem[]): ListRow[] {
|
|||||||
}
|
}
|
||||||
rows.push({ type: 'big', item: bigItem });
|
rows.push({ type: 'big', item: bigItem });
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user