mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
feat: add all source files - services, store, hooks, components, screens
This commit is contained in:
41
app/_layout.tsx
Normal file
41
app/_layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function RootLayout() {
|
||||
const restore = useAuthStore(s => s.restore);
|
||||
|
||||
useEffect(() => {
|
||||
restore();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#00AEEC',
|
||||
tabBarInactiveTintColor: '#999',
|
||||
tabBarStyle: { borderTopColor: '#eee' },
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: '首页',
|
||||
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="video"
|
||||
options={{ href: null }}
|
||||
/>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
app/index.tsx
Normal file
86
app/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View, FlatList, StyleSheet, SafeAreaView,
|
||||
Text, TouchableOpacity, ActivityIndicator, Dimensions
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { VideoCard } from '../components/VideoCard';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { useVideoList } from '../hooks/useVideoList';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const { videos, loading, refreshing, load, refresh } = useVideoList();
|
||||
const { isLoggedIn, logout } = useAuthStore();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const renderItem = ({ item, index }: { item: VideoItem; index: number }) => (
|
||||
<View style={index % 2 === 0 ? styles.leftCol : styles.rightCol}>
|
||||
<VideoCard
|
||||
item={item}
|
||||
onPress={() => router.push(`/video/${item.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.logo}>哔哩哔哩</Text>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity style={styles.headerBtn}>
|
||||
<Ionicons name="search" size={22} color="#212121" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.headerBtn}
|
||||
onPress={() => isLoggedIn ? logout() : setShowLogin(true)}
|
||||
>
|
||||
<Ionicons name={isLoggedIn ? 'person' : 'person-outline'} size={22} color="#00AEEC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabRow}>
|
||||
<Text style={styles.tabActive}>热门</Text>
|
||||
<View style={styles.tabUnderline} />
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={videos}
|
||||
keyExtractor={item => item.bvid}
|
||||
numColumns={2}
|
||||
columnWrapperStyle={styles.row}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={renderItem}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onEndReached={() => load()}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={loading ? <ActivityIndicator style={styles.footer} color="#00AEEC" /> : null}
|
||||
/>
|
||||
|
||||
<LoginModal visible={showLogin} onClose={() => setShowLogin(false)} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: '#f4f4f4' },
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
logo: { fontSize: 20, fontWeight: '800', color: '#00AEEC', letterSpacing: -0.5 },
|
||||
headerRight: { flexDirection: 'row', gap: 8 },
|
||||
headerBtn: { padding: 4 },
|
||||
tabRow: { backgroundColor: '#fff', paddingHorizontal: 16, paddingBottom: 0, flexDirection: 'row', alignItems: 'center', position: 'relative' },
|
||||
tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC', paddingVertical: 10 },
|
||||
tabUnderline: { position: 'absolute', bottom: 0, left: 16, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 },
|
||||
row: { paddingHorizontal: 8 },
|
||||
list: { paddingTop: 8, paddingBottom: 80 },
|
||||
leftCol: { marginLeft: 4, marginRight: 2 },
|
||||
rightCol: { marginLeft: 2, marginRight: 4 },
|
||||
footer: { marginVertical: 16 },
|
||||
});
|
||||
135
app/video/[bvid].tsx
Normal file
135
app/video/[bvid].tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, StyleSheet, SafeAreaView,
|
||||
TouchableOpacity, Image, ActivityIndicator
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { VideoPlayer } from '../../components/VideoPlayer';
|
||||
import { CommentItem } from '../../components/CommentItem';
|
||||
import { useVideoDetail } from '../../hooks/useVideoDetail';
|
||||
import { useComments } from '../../hooks/useComments';
|
||||
import { formatCount } from '../../utils/format';
|
||||
|
||||
type Tab = 'intro' | 'comments';
|
||||
|
||||
export default function VideoDetailScreen() {
|
||||
const { bvid } = useLocalSearchParams<{ bvid: string }>();
|
||||
const router = useRouter();
|
||||
const { video, streamUrl, loading: videoLoading } = useVideoDetail(bvid as string);
|
||||
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
|
||||
const [tab, setTab] = useState<Tab>('comments');
|
||||
|
||||
useEffect(() => {
|
||||
if (video?.aid) loadComments();
|
||||
}, [video?.aid]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color="#212121" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.topTitle} numberOfLines={1}>{video?.title ?? '视频详情'}</Text>
|
||||
</View>
|
||||
|
||||
<VideoPlayer uri={streamUrl} />
|
||||
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
{videoLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : video ? (
|
||||
<>
|
||||
<View style={styles.titleSection}>
|
||||
<Text style={styles.title}>{video.title}</Text>
|
||||
<View style={styles.statsRow}>
|
||||
<StatBadge icon="play" count={video.stat.view} />
|
||||
<StatBadge icon="heart" count={video.stat.like} />
|
||||
<StatBadge icon="star" count={video.stat.favorite} />
|
||||
<StatBadge icon="chatbubble" count={video.stat.reply} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.upRow}>
|
||||
<Image source={{ uri: video.owner.face }} style={styles.avatar} />
|
||||
<Text style={styles.upName}>{video.owner.name}</Text>
|
||||
<TouchableOpacity style={styles.followBtn}>
|
||||
<Text style={styles.followTxt}>+ 关注</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabBar}>
|
||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab('intro')}>
|
||||
<Text style={[styles.tabLabel, tab === 'intro' && styles.tabActive]}>简介</Text>
|
||||
{tab === 'intro' && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab('comments')}>
|
||||
<Text style={[styles.tabLabel, tab === 'comments' && styles.tabActive]}>
|
||||
评论 {video.stat.reply > 0 ? formatCount(video.stat.reply) : ''}
|
||||
</Text>
|
||||
{tab === 'comments' && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{tab === 'intro' ? (
|
||||
<View style={styles.descBox}>
|
||||
<Text style={styles.descText}>{video.desc || '暂无简介'}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{comments.map(c => <CommentItem key={c.rpid} item={c} />)}
|
||||
{cmtLoading && <ActivityIndicator style={styles.loader} color="#00AEEC" />}
|
||||
{!cmtLoading && comments.length > 0 && (
|
||||
<TouchableOpacity style={styles.loadMore} onPress={loadComments}>
|
||||
<Text style={styles.loadMoreTxt}>加载更多评论</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!cmtLoading && comments.length === 0 && !videoLoading && (
|
||||
<Text style={styles.emptyTxt}>暂无评论</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBadge({ icon, count }: { icon: string; count: number }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Ionicons name={icon as any} size={14} color="#999" />
|
||||
<Text style={styles.statText}>{formatCount(count)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: '#fff' },
|
||||
topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
backBtn: { padding: 4 },
|
||||
topTitle: { flex: 1, fontSize: 15, fontWeight: '600', marginLeft: 4, color: '#212121' },
|
||||
scroll: { flex: 1 },
|
||||
loader: { marginVertical: 30 },
|
||||
titleSection: { padding: 14 },
|
||||
title: { fontSize: 16, fontWeight: '600', color: '#212121', lineHeight: 22, marginBottom: 8 },
|
||||
statsRow: { flexDirection: 'row', gap: 16 },
|
||||
stat: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||
statText: { fontSize: 12, color: '#999' },
|
||||
upRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0' },
|
||||
avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 },
|
||||
upName: { flex: 1, fontSize: 14, color: '#212121', fontWeight: '500' },
|
||||
followBtn: { backgroundColor: '#00AEEC', paddingHorizontal: 14, paddingVertical: 5, borderRadius: 14 },
|
||||
followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' },
|
||||
tabBar: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12, position: 'relative' },
|
||||
tabLabel: { fontSize: 14, color: '#999' },
|
||||
tabActive: { color: '#00AEEC', fontWeight: '700' },
|
||||
tabUnderline: { position: 'absolute', bottom: 0, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 },
|
||||
descBox: { padding: 16 },
|
||||
descText: { fontSize: 14, color: '#555', lineHeight: 22 },
|
||||
loadMore: { alignItems: 'center', padding: 16 },
|
||||
loadMoreTxt: { color: '#00AEEC', fontSize: 13 },
|
||||
emptyTxt: { textAlign: 'center', color: '#bbb', padding: 30 },
|
||||
});
|
||||
9
app/video/_layout.tsx
Normal file
9
app/video/_layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function VideoLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="[bvid]" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
38
components/CommentItem.tsx
Normal file
38
components/CommentItem.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { Comment } from '../services/types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
interface Props { item: Comment; }
|
||||
|
||||
export function CommentItem({ item }: Props) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Image source={{ uri: item.member.avatar }} style={styles.avatar} />
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.username}>{item.member.uname}</Text>
|
||||
<Text style={styles.message}>{item.content.message}</Text>
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.time}>{formatTime(item.ctime)}</Text>
|
||||
<View style={styles.likeRow}>
|
||||
<Ionicons name="thumbs-up-outline" size={12} color="#999" />
|
||||
<Text style={styles.likeCount}>{item.like > 0 ? item.like : ''}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
avatar: { width: 34, height: 34, borderRadius: 17, marginRight: 10 },
|
||||
content: { flex: 1 },
|
||||
username: { fontSize: 12, color: '#00AEEC', marginBottom: 3 },
|
||||
message: { fontSize: 14, color: '#212121', lineHeight: 20 },
|
||||
footer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||
time: { fontSize: 11, color: '#bbb' },
|
||||
likeRow: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
||||
likeCount: { fontSize: 11, color: '#999' },
|
||||
});
|
||||
79
components/LoginModal.tsx
Normal file
79
components/LoginModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
|
||||
import { generateQRCode, pollQRCode } from '../services/bilibili';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ visible, onClose }: Props) {
|
||||
const [qrUrl, setQrUrl] = useState<string | null>(null);
|
||||
const [qrKey, setQrKey] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading');
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const login = useAuthStore(s => s.login);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setStatus('loading');
|
||||
setQrUrl(null);
|
||||
setQrKey(null);
|
||||
generateQRCode().then(data => {
|
||||
setQrUrl(`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.url)}&size=200x200`);
|
||||
setQrKey(data.qrcode_key);
|
||||
setStatus('waiting');
|
||||
}).catch(() => setStatus('error'));
|
||||
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrKey || status !== 'waiting') return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
const result = await pollQRCode(qrKey);
|
||||
if (result.code === 86038) { setStatus('error'); clearInterval(pollRef.current!); }
|
||||
if (result.code === 86090) setStatus('scanned');
|
||||
if (result.code === 0 && result.cookie) {
|
||||
clearInterval(pollRef.current!);
|
||||
await login(result.cookie, '', '');
|
||||
setStatus('done');
|
||||
onClose();
|
||||
}
|
||||
}, 2000);
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [qrKey, status]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.title}>扫码登录</Text>
|
||||
{status === 'loading' && <ActivityIndicator size="large" color="#00AEEC" style={styles.loader} />}
|
||||
{(status === 'waiting' || status === 'scanned') && qrUrl && (
|
||||
<>
|
||||
<Image source={{ uri: qrUrl }} style={styles.qr} />
|
||||
<Text style={styles.hint}>{status === 'scanned' ? '扫描成功,请在手机确认' : '使用 B站 APP 扫一扫'}</Text>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && <Text style={styles.hint}>二维码已过期,请关闭重试</Text>}
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||
<Text style={styles.closeTxt}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
sheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24, alignItems: 'center' },
|
||||
title: { fontSize: 18, fontWeight: '600', marginBottom: 20 },
|
||||
loader: { marginVertical: 40 },
|
||||
qr: { width: 200, height: 200, marginBottom: 12 },
|
||||
hint: { fontSize: 13, color: '#666', marginBottom: 20 },
|
||||
closeBtn: { padding: 12 },
|
||||
closeTxt: { fontSize: 14, color: '#00AEEC' },
|
||||
});
|
||||
51
components/VideoCard.tsx
Normal file
51
components/VideoCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { VideoItem } from '../services/types';
|
||||
import { formatCount, formatDuration } from '../utils/format';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_WIDTH = (width - 24) / 2;
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function VideoCard({ item, onPress }: Props) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
|
||||
<View style={styles.thumbContainer}>
|
||||
<Image
|
||||
source={{ uri: item.pic }}
|
||||
style={styles.thumb}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
||||
<View style={styles.meta}>
|
||||
<Ionicons name="play" size={11} color="#999" />
|
||||
<Text style={styles.metaText}>{formatCount(item.stat.view)}</Text>
|
||||
</View>
|
||||
<Text style={styles.owner} numberOfLines={1}>{item.owner.name}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { width: CARD_WIDTH, marginBottom: 12, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
|
||||
thumbContainer: { position: 'relative' },
|
||||
thumb: { width: CARD_WIDTH, height: CARD_WIDTH * 0.5625 },
|
||||
durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1 },
|
||||
durationText: { color: '#fff', fontSize: 10 },
|
||||
info: { padding: 6 },
|
||||
title: { fontSize: 12, color: '#212121', lineHeight: 16, marginBottom: 4 },
|
||||
meta: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
||||
metaText: { fontSize: 11, color: '#999' },
|
||||
owner: { fontSize: 11, color: '#999', marginTop: 2 },
|
||||
});
|
||||
47
components/VideoPlayer.tsx
Normal file
47
components/VideoPlayer.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { View, StyleSheet, Dimensions, Text } from 'react-native';
|
||||
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
|
||||
interface Props {
|
||||
uri: string | null;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ uri }: Props) {
|
||||
const videoRef = useRef<Video>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const onPlaybackStatusUpdate = (s: AVPlaybackStatus) => {
|
||||
if (s.isLoaded) setIsPlaying(s.isPlaying);
|
||||
};
|
||||
|
||||
if (!uri) {
|
||||
return (
|
||||
<View style={[styles.container, styles.placeholder]}>
|
||||
<Text style={styles.placeholderText}>视频加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={{ uri, headers: { Referer: 'https://www.bilibili.com' } }}
|
||||
style={styles.video}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
onPlaybackStatusUpdate={onPlaybackStatusUpdate}
|
||||
useNativeControls
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
|
||||
video: { width, height: VIDEO_HEIGHT },
|
||||
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { color: '#fff', fontSize: 14 },
|
||||
});
|
||||
27
hooks/useComments.ts
Normal file
27
hooks/useComments.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getComments } from '../services/bilibili';
|
||||
import type { Comment } from '../services/types';
|
||||
|
||||
export function useComments(aid: number) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loading || !hasMore || !aid) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getComments(aid, page);
|
||||
if (data.length === 0) { setHasMore(false); return; }
|
||||
setComments(prev => [...prev, ...data]);
|
||||
setPage(p => p + 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to load comments', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [aid, page, loading, hasMore]);
|
||||
|
||||
return { comments, loading, hasMore, load };
|
||||
}
|
||||
30
hooks/useVideoDetail.ts
Normal file
30
hooks/useVideoDetail.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getVideoDetail, getPlayUrl } from '../services/bilibili';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export function useVideoDetail(bvid: string) {
|
||||
const [video, setVideo] = useState<VideoItem | null>(null);
|
||||
const [streamUrl, setStreamUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const detail = await getVideoDetail(bvid);
|
||||
setVideo(detail);
|
||||
const cid = detail.pages?.[0]?.cid ?? detail.cid;
|
||||
const playData = await getPlayUrl(bvid, cid);
|
||||
setStreamUrl(playData.durl[0]?.url ?? null);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Load failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (bvid) fetchData();
|
||||
}, [bvid]);
|
||||
|
||||
return { video, streamUrl, loading, error };
|
||||
}
|
||||
34
hooks/useVideoList.ts
Normal file
34
hooks/useVideoList.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getPopularVideos } from '../services/bilibili';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export function useVideoList() {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async (reset = false) => {
|
||||
if (loading) return;
|
||||
const nextPage = reset ? 1 : page;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getPopularVideos(nextPage);
|
||||
setVideos(prev => reset ? data : [...prev, ...data]);
|
||||
setPage(nextPage + 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to load videos', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [loading, page]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
load(true);
|
||||
}, [load]);
|
||||
|
||||
return { videos, loading, refreshing, load, refresh };
|
||||
}
|
||||
71
services/bilibili.ts
Normal file
71
services/bilibili.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import axios from 'axios';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo } from './types';
|
||||
|
||||
const BASE = 'https://api.bilibili.com';
|
||||
const PASSPORT = 'https://passport.bilibili.com';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Referer': 'https://www.bilibili.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 Chrome/91.0',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
const sessdata = await AsyncStorage.getItem('SESSDATA');
|
||||
if (sessdata) {
|
||||
config.headers['Cookie'] = `SESSDATA=${sessdata}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
|
||||
const res = await api.get('/x/web-interface/popular', { params: { pn, ps: 20 } });
|
||||
return res.data.data.list as VideoItem[];
|
||||
}
|
||||
|
||||
export async function getVideoDetail(bvid: string): Promise<VideoItem> {
|
||||
const res = await api.get('/x/web-interface/view', { params: { bvid } });
|
||||
return res.data.data as VideoItem;
|
||||
}
|
||||
|
||||
export async function getPlayUrl(bvid: string, cid: number): Promise<PlayUrlResponse> {
|
||||
const res = await api.get('/x/player/playurl', {
|
||||
params: { bvid, cid, qn: 64, fnval: 1 },
|
||||
});
|
||||
return res.data.data as PlayUrlResponse;
|
||||
}
|
||||
|
||||
export async function getComments(aid: number, pn = 1): Promise<Comment[]> {
|
||||
const res = await api.get('/x/v2/reply', {
|
||||
params: { oid: aid, type: 1, pn, ps: 20, sort: 2 },
|
||||
});
|
||||
return (res.data.data?.replies ?? []) as Comment[];
|
||||
}
|
||||
|
||||
export async function generateQRCode(): Promise<QRCodeInfo> {
|
||||
const res = await axios.get(`${PASSPORT}/x/passport-login/web/qrcode/generate`, {
|
||||
headers: { 'Referer': 'https://www.bilibili.com' },
|
||||
});
|
||||
return res.data.data as QRCodeInfo;
|
||||
}
|
||||
|
||||
export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string }> {
|
||||
const res = await axios.get(`${PASSPORT}/x/passport-login/web/qrcode/poll`, {
|
||||
params: { qrcode_key },
|
||||
headers: { 'Referer': 'https://www.bilibili.com' },
|
||||
});
|
||||
const { code } = res.data.data;
|
||||
let cookie: string | undefined;
|
||||
if (code === 0) {
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
const match = setCookie?.find((c: string) => c.includes('SESSDATA'));
|
||||
if (match) {
|
||||
cookie = match.split(';')[0].replace('SESSDATA=', '');
|
||||
}
|
||||
}
|
||||
return { code, cookie };
|
||||
}
|
||||
49
services/types.ts
Normal file
49
services/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface VideoItem {
|
||||
bvid: string;
|
||||
aid: number;
|
||||
title: string;
|
||||
pic: string;
|
||||
owner: {
|
||||
mid: number;
|
||||
name: string;
|
||||
face: string;
|
||||
};
|
||||
stat: {
|
||||
view: number;
|
||||
danmaku: number;
|
||||
reply: number;
|
||||
like: number;
|
||||
coin: number;
|
||||
favorite: number;
|
||||
};
|
||||
duration: number;
|
||||
desc: string;
|
||||
cid: number;
|
||||
pages?: Array<{ cid: number; part: string }>;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
rpid: number;
|
||||
content: { message: string };
|
||||
member: {
|
||||
uname: string;
|
||||
avatar: string;
|
||||
};
|
||||
like: number;
|
||||
ctime: number;
|
||||
replies: Comment[] | null;
|
||||
}
|
||||
|
||||
export interface PlayUrlResponse {
|
||||
durl: Array<{
|
||||
url: string;
|
||||
length: number;
|
||||
size: number;
|
||||
}>;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export interface QRCodeInfo {
|
||||
url: string;
|
||||
qrcode_key: string;
|
||||
}
|
||||
42
store/authStore.ts
Normal file
42
store/authStore.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface AuthState {
|
||||
sessdata: string | null;
|
||||
uid: string | null;
|
||||
username: string | null;
|
||||
isLoggedIn: boolean;
|
||||
login: (sessdata: string, uid: string, username?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
sessdata: null,
|
||||
uid: null,
|
||||
username: null,
|
||||
isLoggedIn: false,
|
||||
|
||||
login: async (sessdata, uid, username) => {
|
||||
await AsyncStorage.multiSet([
|
||||
['SESSDATA', sessdata],
|
||||
['UID', uid],
|
||||
['USERNAME', username ?? ''],
|
||||
]);
|
||||
set({ sessdata, uid, username: username ?? null, isLoggedIn: true });
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await AsyncStorage.multiRemove(['SESSDATA', 'UID', 'USERNAME']);
|
||||
set({ sessdata: null, uid: null, username: null, isLoggedIn: false });
|
||||
},
|
||||
|
||||
restore: async () => {
|
||||
const sessdata = await AsyncStorage.getItem('SESSDATA');
|
||||
const uid = await AsyncStorage.getItem('UID');
|
||||
const username = await AsyncStorage.getItem('USERNAME');
|
||||
if (sessdata) {
|
||||
set({ sessdata, uid, username, isLoggedIn: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
16
utils/format.ts
Normal file
16
utils/format.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function formatCount(n: number): string {
|
||||
if (n >= 100_000_000) return (n / 100_000_000).toFixed(1) + '亿';
|
||||
if (n >= 10_000) return (n / 10_000).toFixed(1) + '万';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatTime(ctime: number): string {
|
||||
const d = new Date(ctime * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user