mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
- expo-image 替换 RN Image(VideoCard/LiveCard/BigVideoCard/CommentItem,recyclingKey) - DanmakuList Animated.Value 对象池,减少 GC 压力 - FlatList 性能参数:windowSize=7 / maxToRenderPerBatch=6 / removeClippedSubviews - bilibili.ts 请求去重(getVideoDetail/getPlayUrl) - SESSDATA 迁移至 expo-secure-store(utils/secureStorage.ts,启动自动迁移) - 主题系统扩展:新增 sheetBg/modalBg/modalText/placeholder/iconDefault/danger 等 token - 多组件深色模式适配:DownloadSheet/LivePlayer/NativeVideoPlayer/DownloadProgressBtn/CommentItem/LoginModal - 搜索页增强:搜索建议 + 热搜榜(hooks/useSearch.ts + app/search.tsx) - LoginModal 修复轮询竞态(cancelled flag + try-catch) - 修复 downloads.tsx 冗余三元表达式
241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
import React, { useEffect, useState, useRef } from "react";
|
||
import {
|
||
Modal,
|
||
View,
|
||
Text,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
ActivityIndicator,
|
||
Animated,
|
||
Alert,
|
||
Linking,
|
||
} from "react-native";
|
||
import * as FileSystem from "expo-file-system/legacy";
|
||
import * as MediaLibrary from "expo-media-library";
|
||
import QRCode from "react-native-qrcode-svg";
|
||
import { Ionicons } from "@expo/vector-icons";
|
||
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
|
||
import { useAuthStore } from "../store/authStore";
|
||
import { useTheme } from "../utils/theme";
|
||
|
||
interface Props {
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function LoginModal({ visible, onClose }: Props) {
|
||
const [qrData, setQrData] = useState<string | null>(null);
|
||
const [qrKey, setQrKey] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const qrRef = useRef<any>(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);
|
||
const setProfile = useAuthStore((s) => s.setProfile);
|
||
const theme = useTheme();
|
||
|
||
// sheet 滑入动画
|
||
const slideY = useRef(new Animated.Value(300)).current;
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
Animated.spring(slideY, {
|
||
toValue: 0,
|
||
useNativeDriver: true,
|
||
bounciness: 4,
|
||
}).start();
|
||
} else {
|
||
slideY.setValue(300);
|
||
}
|
||
}, [visible]);
|
||
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
setStatus("loading");
|
||
setQrData(null);
|
||
setQrKey(null);
|
||
generateQRCode()
|
||
.then((data) => {
|
||
setQrData(data.url);
|
||
setQrKey(data.qrcode_key);
|
||
setStatus("waiting");
|
||
})
|
||
.catch(() => setStatus("error"));
|
||
|
||
return () => {
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
};
|
||
}, [visible]);
|
||
|
||
useEffect(() => {
|
||
if (!qrKey || status !== "waiting") return;
|
||
let cancelled = false;
|
||
pollRef.current = setInterval(async () => {
|
||
if (cancelled) return;
|
||
try {
|
||
const result = await pollQRCode(qrKey);
|
||
if (cancelled) return;
|
||
if (result.code === 86038) {
|
||
setStatus("error");
|
||
clearInterval(pollRef.current!);
|
||
}
|
||
if (result.code === 86090) setStatus("scanned");
|
||
if (result.code === 0 && result.cookie) {
|
||
clearInterval(pollRef.current!);
|
||
try {
|
||
await login(result.cookie, "", "");
|
||
if (cancelled) return;
|
||
setStatus("done");
|
||
const info = await getUserInfo();
|
||
if (!cancelled) setProfile(info.face, info.uname, String(info.mid));
|
||
} catch {
|
||
if (!cancelled) setStatus("error");
|
||
}
|
||
onClose();
|
||
}
|
||
} catch {
|
||
// Network error during poll — ignore, will retry next interval
|
||
}
|
||
}, 2000);
|
||
return () => {
|
||
cancelled = true;
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
};
|
||
}, [qrKey, status]);
|
||
|
||
async function handleSaveQR() {
|
||
if (!qrRef.current) return;
|
||
setSaving(true);
|
||
try {
|
||
const { status: perm } = await MediaLibrary.requestPermissionsAsync();
|
||
if (perm !== "granted") {
|
||
Alert.alert("提示", "需要相册权限才能保存图片");
|
||
return;
|
||
}
|
||
qrRef.current.toDataURL(async (base64: string) => {
|
||
try {
|
||
const dest = `${FileSystem.cacheDirectory}bilibili_qr.png`;
|
||
await FileSystem.writeAsStringAsync(dest, base64, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
});
|
||
await MediaLibrary.saveToLibraryAsync(dest);
|
||
Alert.alert("已保存", "二维码已存入相册,请用哔哩哔哩扫码登录", [
|
||
{ text: "关闭", style: "cancel" },
|
||
{
|
||
text: "打开哔哩哔哩",
|
||
onPress: () => Linking.openURL("bilibili://"),
|
||
},
|
||
]);
|
||
} catch {
|
||
Alert.alert("失败", "保存失败,请重试");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
});
|
||
} catch {
|
||
Alert.alert("失败", "保存失败,请重试");
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
visible={visible}
|
||
transparent
|
||
animationType="none"
|
||
onRequestClose={onClose}
|
||
>
|
||
{/* 遮罩固定不动 */}
|
||
<View style={styles.overlay} pointerEvents="box-none" />
|
||
|
||
{/* sheet 独立滑入 */}
|
||
<Animated.View
|
||
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
|
||
>
|
||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||
<Text style={[styles.title, { color: theme.modalText }]}>扫码登录</Text>
|
||
{status === "loading" && (
|
||
<ActivityIndicator
|
||
size="large"
|
||
color="#00AEEC"
|
||
style={styles.loader}
|
||
/>
|
||
)}
|
||
{(status === "waiting" || status === "scanned") && qrData && (
|
||
<>
|
||
<View style={styles.qrWrapper}>
|
||
<QRCode
|
||
value={qrData}
|
||
size={200}
|
||
getRef={(ref) => { qrRef.current = ref; }}
|
||
/>
|
||
<TouchableOpacity
|
||
style={styles.saveBtn}
|
||
onPress={handleSaveQR}
|
||
disabled={saving}
|
||
>
|
||
{saving ? (
|
||
<ActivityIndicator size="small" color="#fff" />
|
||
) : (
|
||
<Ionicons name="download-outline" size={16} color="#fff" />
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
<Text style={[styles.hint, { color: theme.modalTextSub }]}>
|
||
{status === "scanned"
|
||
? "扫描成功,请在手机确认"
|
||
: "使用 B站 APP 扫一扫"}
|
||
</Text>
|
||
</>
|
||
)}
|
||
{status === "error" && (
|
||
<Text style={[styles.hint, { color: theme.modalTextSub }]}>二维码已过期,请关闭重试</Text>
|
||
)}
|
||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||
<Text style={styles.closeTxt}>关闭</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Animated.View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
overlay: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: "rgba(0,0,0,0.5)",
|
||
},
|
||
sheetWrapper: {
|
||
position: "absolute",
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
},
|
||
sheet: {
|
||
backgroundColor: "#fff",
|
||
borderTopLeftRadius: 16,
|
||
borderTopRightRadius: 16,
|
||
padding: 24,
|
||
alignItems: "center",
|
||
},
|
||
title: { fontSize: 18, fontWeight: "600", marginBottom: 20 },
|
||
loader: { marginVertical: 40 },
|
||
qrWrapper: { width: 200, height: 200, marginBottom: 12 },
|
||
saveBtn: {
|
||
position: "absolute",
|
||
bottom: 6,
|
||
right: 6,
|
||
backgroundColor: "rgba(0,0,0,0.45)",
|
||
borderRadius: 14,
|
||
width: 28,
|
||
height: 28,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
hint: { fontSize: 13, color: "#666", marginBottom: 20 },
|
||
closeBtn: { padding: 12 },
|
||
closeTxt: { fontSize: 14, color: "#00AEEC" },
|
||
});
|