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