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:
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user