diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..adfd957 --- /dev/null +++ b/app/_layout.tsx @@ -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 ( + <> + + + ( + + ), + }} + /> + + + + ); +} diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..f7df5a5 --- /dev/null +++ b/app/index.tsx @@ -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 }) => ( + + router.push(`/video/${item.bvid}` as any)} + /> + + ); + + return ( + + + 哔哩哔哩 + + + + + isLoggedIn ? logout() : setShowLogin(true)} + > + + + + + + + 热门 + + + + item.bvid} + numColumns={2} + columnWrapperStyle={styles.row} + contentContainerStyle={styles.list} + renderItem={renderItem} + onRefresh={refresh} + refreshing={refreshing} + onEndReached={() => load()} + onEndReachedThreshold={0.5} + ListFooterComponent={loading ? : null} + /> + + setShowLogin(false)} /> + + ); +} + +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 }, +}); diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx new file mode 100644 index 0000000..3d842a3 --- /dev/null +++ b/app/video/[bvid].tsx @@ -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('comments'); + + useEffect(() => { + if (video?.aid) loadComments(); + }, [video?.aid]); + + return ( + + + router.back()} style={styles.backBtn}> + + + {video?.title ?? '视频详情'} + + + + + + {videoLoading ? ( + + ) : video ? ( + <> + + {video.title} + + + + + + + + + + + {video.owner.name} + + + 关注 + + + + + setTab('intro')}> + 简介 + {tab === 'intro' && } + + setTab('comments')}> + + 评论 {video.stat.reply > 0 ? formatCount(video.stat.reply) : ''} + + {tab === 'comments' && } + + + + {tab === 'intro' ? ( + + {video.desc || '暂无简介'} + + ) : ( + <> + {comments.map(c => )} + {cmtLoading && } + {!cmtLoading && comments.length > 0 && ( + + 加载更多评论 + + )} + {!cmtLoading && comments.length === 0 && !videoLoading && ( + 暂无评论 + )} + + )} + + ) : null} + + + ); +} + +function StatBadge({ icon, count }: { icon: string; count: number }) { + return ( + + + {formatCount(count)} + + ); +} + +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 }, +}); diff --git a/app/video/_layout.tsx b/app/video/_layout.tsx new file mode 100644 index 0000000..f3b4486 --- /dev/null +++ b/app/video/_layout.tsx @@ -0,0 +1,9 @@ +import { Stack } from 'expo-router'; + +export default function VideoLayout() { + return ( + + + + ); +} diff --git a/components/CommentItem.tsx b/components/CommentItem.tsx new file mode 100644 index 0000000..325a374 --- /dev/null +++ b/components/CommentItem.tsx @@ -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 ( + + + + {item.member.uname} + {item.content.message} + + {formatTime(item.ctime)} + + + {item.like > 0 ? item.like : ''} + + + + + ); +} + +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' }, +}); diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx new file mode 100644 index 0000000..6f2bdd7 --- /dev/null +++ b/components/LoginModal.tsx @@ -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(null); + const [qrKey, setQrKey] = useState(null); + const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading'); + const pollRef = useRef | 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 ( + + + + 扫码登录 + {status === 'loading' && } + {(status === 'waiting' || status === 'scanned') && qrUrl && ( + <> + + {status === 'scanned' ? '扫描成功,请在手机确认' : '使用 B站 APP 扫一扫'} + + )} + {status === 'error' && 二维码已过期,请关闭重试} + + 关闭 + + + + + ); +} + +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' }, +}); diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx new file mode 100644 index 0000000..c58592d --- /dev/null +++ b/components/VideoCard.tsx @@ -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 ( + + + + + {formatDuration(item.duration)} + + + + {item.title} + + + {formatCount(item.stat.view)} + + {item.owner.name} + + + ); +} + +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 }, +}); diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx new file mode 100644 index 0000000..3664251 --- /dev/null +++ b/components/VideoPlayer.tsx @@ -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