feat: add all source files - services, store, hooks, components, screens

This commit is contained in:
Developer
2026-03-05 18:02:54 +08:00
parent bbe0df6ed2
commit a0e53bd073
15 changed files with 755 additions and 0 deletions

View 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
View 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
View 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 },
});

View 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 },
});