feat: UP主页/视频详情页 UI 优化 + 多处交互修复

- creator: 头部模糊背景 + 滚动渐变 topBar,移除 removeClippedSubviews 修复列表抖动
- video: 简介 2 行折叠(onTextLayout 探测真实行数),tname 分区标签,统计数据前移到 UP 主行
- VideoCard/LiveCard: title 设 minHeight 解决两列卡片底部对齐
- LoginModal: 二维码过期支持原地重试,无需关闭重开
- search: 初始 loading 显示 indicator,搜索/选建议时自动收起键盘
- 统一图片组件为 expo-image:LiveCard / FollowedLiveStrip / downloads
- types: VideoItem 补 tname / pubdate 字段
This commit is contained in:
Developer
2026-05-05 12:16:35 +08:00
parent 4706845f44
commit b0929a8094
9 changed files with 397 additions and 406 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { import {
View, View,
Text, Text,
@@ -6,25 +6,33 @@ import {
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
RefreshControl,
Animated,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { getUploaderInfo, getUploaderVideos } from '../../services/bilibili'; import { getUploaderInfo, getUploaderVideos } from '../../services/bilibili';
import type { VideoItem } from '../../services/types'; import type { VideoItem } from '../../services/types';
import { useTheme } from '../../utils/theme'; import { useTheme } from '../../utils/theme';
import { formatCount, formatDuration } from '../../utils/format'; import { formatCount, formatDuration, formatTime } from '../../utils/format';
import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl'; import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const TOPBAR_HEIGHT = 44;
const FADE_START = 80;
const FADE_END = 160;
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<VideoItem>);
export default function CreatorScreen() { export default function CreatorScreen() {
const { mid: midStr } = useLocalSearchParams<{ mid: string }>(); const { mid: midStr } = useLocalSearchParams<{ mid: string }>();
const mid = Number(midStr); const mid = Number(midStr);
const router = useRouter(); const router = useRouter();
const theme = useTheme(); const theme = useTheme();
const insets = useSafeAreaInsets();
const trafficSaving = useSettingsStore(s => s.trafficSaving); const trafficSaving = useSettingsStore(s => s.trafficSaving);
const [info, setInfo] = useState<{ const [info, setInfo] = useState<{
@@ -35,7 +43,15 @@ export default function CreatorScreen() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [infoLoading, setInfoLoading] = useState(true); const [infoLoading, setInfoLoading] = useState(true);
const loadingRef = React.useRef(false); const [refreshing, setRefreshing] = useState(false);
const loadingRef = useRef(false);
const scrollY = useRef(new Animated.Value(0)).current;
const topBarOpacity = scrollY.interpolate({
inputRange: [FADE_START, FADE_END],
outputRange: [0, 1],
extrapolate: 'clamp',
});
useEffect(() => { useEffect(() => {
getUploaderInfo(mid) getUploaderInfo(mid)
@@ -61,35 +77,46 @@ export default function CreatorScreen() {
} }
}, [mid]); }, [mid]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
const [infoData, { videos: newVideos, total: t }] = await Promise.all([
getUploaderInfo(mid),
getUploaderVideos(mid, 1, PAGE_SIZE),
]);
setInfo(infoData);
setTotal(t);
setVideos(newVideos);
setPage(1);
} catch {}
finally {
setRefreshing(false);
}
}, [mid]);
const hasMore = videos.length < total; const hasMore = videos.length < total;
return ( const HeroHeader = (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}> <View style={[styles.hero, { borderBottomColor: theme.border }]}>
{/* Top bar */} {info ? (
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}> <>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}> <Image
<Ionicons name="chevron-back" size={24} color={theme.text} /> source={{ uri: proxyImageUrl(info.face) }}
</TouchableOpacity> style={styles.heroBg}
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}> contentFit="cover"
{info?.name ?? 'UP主主页'} blurRadius={20}
</Text> />
<View style={styles.backBtn} /> <View style={[styles.heroOverlay, { backgroundColor: theme.card }]} />
</>
) : (
<View style={[styles.heroBg, { backgroundColor: theme.card }]} />
)}
{infoLoading ? (
<View style={[styles.profileContent, { paddingTop: TOPBAR_HEIGHT + 24 }]}>
<ActivityIndicator color="#00AEEC" />
</View> </View>
<FlatList
data={videos}
keyExtractor={item => item.bvid}
showsVerticalScrollIndicator={false}
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
onEndReachedThreshold={0.3}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews
ListHeaderComponent={
infoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : info ? ( ) : info ? (
<View style={[styles.profileCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}> <View style={[styles.profileContent, { paddingTop: TOPBAR_HEIGHT + 12 }]}>
<Image <Image
source={{ uri: proxyImageUrl(info.face) }} source={{ uri: proxyImageUrl(info.face) }}
style={styles.avatar} style={styles.avatar}
@@ -115,8 +142,38 @@ export default function CreatorScreen() {
{total} {total}
</Text> </Text>
</View> </View>
) : null ) : null}
</View>
);
return (
<SafeAreaView
style={[styles.safe, { backgroundColor: theme.card }]}
edges={['top', 'left', 'right']}
>
<AnimatedFlatList
data={videos}
keyExtractor={item => item.bvid}
showsVerticalScrollIndicator={false}
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
onEndReachedThreshold={0.3}
windowSize={7}
maxToRenderPerBatch={6}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true },
)}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#00AEEC"
colors={["#00AEEC"]}
progressViewOffset={insets.top + TOPBAR_HEIGHT}
/>
} }
ListHeaderComponent={HeroHeader}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity <TouchableOpacity
style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]} style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
@@ -141,7 +198,10 @@ export default function CreatorScreen() {
</Text> </Text>
<View style={styles.videoMeta}> <View style={styles.videoMeta}>
<Ionicons name="play" size={11} color={theme.textSub} /> <Ionicons name="play" size={11} color={theme.textSub} />
<Text style={[styles.metaText, { color: theme.textSub }]}>{formatCount(item.stat.view)}</Text> <Text style={[styles.metaText, { color: theme.textSub }]}>{formatCount(item.stat?.view ?? 0)}</Text>
{!!item.pubdate && (
<Text style={[styles.metaText, { color: theme.textSub }]}>· {formatTime(item.pubdate)}</Text>
)}
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -152,30 +212,93 @@ export default function CreatorScreen() {
) : null ) : null
} }
ListFooterComponent={ ListFooterComponent={
loading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null <View style={styles.footer}>
{loading && <ActivityIndicator color="#00AEEC" />}
</View>
} }
/> />
{/* 浮动 topBar背景透明 → 不透明,跟随滚动 */}
<View
style={[styles.topBarFloat, { height: TOPBAR_HEIGHT, top: insets.top }]}
pointerEvents="box-none"
>
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFillObject,
{ backgroundColor: theme.card, opacity: topBarOpacity },
]}
/>
<Animated.View
pointerEvents="none"
style={[
styles.topBarBorder,
{ backgroundColor: theme.border, opacity: topBarOpacity },
]}
/>
<View style={styles.topBarContent}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Animated.Text
style={[styles.topTitle, { color: theme.text, opacity: topBarOpacity }]}
numberOfLines={1}
>
{info?.name ?? 'UP主主页'}
</Animated.Text>
<View style={styles.backBtn} />
</View>
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1 }, safe: { flex: 1 },
topBar: { hero: {
overflow: 'hidden',
position: 'relative',
borderBottomWidth: StyleSheet.hairlineWidth,
},
heroBg: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
heroOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.82,
},
topBarFloat: {
position: 'absolute',
left: 0,
right: 0,
},
topBarBorder: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: StyleSheet.hairlineWidth,
},
topBarContent: {
flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
}, },
backBtn: { padding: 4, width: 32 }, backBtn: { padding: 4, width: 32 },
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' }, topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
profileCard: { profileContent: {
alignItems: 'center', alignItems: 'center',
paddingTop: 24, paddingBottom: 16,
paddingBottom: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
marginBottom: 4,
}, },
avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 }, avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 },
name: { fontSize: 18, fontWeight: '700', marginBottom: 6 }, name: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
@@ -185,8 +308,7 @@ const styles = StyleSheet.create({
statNum: { fontSize: 18, fontWeight: '700' }, statNum: { fontSize: 18, fontWeight: '700' },
statLabel: { fontSize: 12, marginTop: 2 }, statLabel: { fontSize: 12, marginTop: 2 },
statDivider: { width: 1, height: 28 }, statDivider: { width: 1, height: 28 },
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 8 }, videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 4 },
loader: { marginVertical: 24 },
videoRow: { videoRow: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 12, paddingHorizontal: 12,
@@ -203,7 +325,8 @@ const styles = StyleSheet.create({
durationText: { color: '#fff', fontSize: 10 }, durationText: { color: '#fff', fontSize: 10 },
videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 }, videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 },
videoTitle: { fontSize: 13, lineHeight: 18 }, videoTitle: { fontSize: 13, lineHeight: 18 },
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 3 }, videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 4 },
metaText: { fontSize: 12 }, metaText: { fontSize: 12 },
emptyTxt: { textAlign: 'center', padding: 40 }, emptyTxt: { textAlign: 'center', padding: 40 },
footer: { height: 48, justifyContent: 'center', alignItems: 'center' },
}); });

View File

@@ -5,11 +5,11 @@ import {
SectionList, SectionList,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Image,
Modal, Modal,
StatusBar, StatusBar,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -171,7 +171,7 @@ function DownloadRow({
const rowContent = ( const rowContent = (
<View style={[styles.row, { backgroundColor: theme.card }]}> <View style={[styles.row, { backgroundColor: theme.card }]}>
<Image source={{ uri: proxyImageUrl(task.cover) }} style={styles.cover} /> <Image source={{ uri: proxyImageUrl(task.cover) }} style={styles.cover} contentFit="cover" recyclingKey={task.bvid} />
<View style={styles.info}> <View style={styles.info}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>{task.title}</Text> <Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>{task.title}</Text>
<Text style={[styles.qdesc, { color: theme.textSub }]}> <Text style={[styles.qdesc, { color: theme.textSub }]}>

View File

@@ -8,6 +8,7 @@ import {
FlatList, FlatList,
ActivityIndicator, ActivityIndicator,
ScrollView, ScrollView,
Keyboard,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -43,12 +44,14 @@ export default function SearchScreen() {
const term = (kw ?? keyword).trim(); const term = (kw ?? keyword).trim();
if (term) { if (term) {
if (kw) setKeyword(kw); if (kw) setKeyword(kw);
Keyboard.dismiss();
search(kw ?? keyword, true); search(kw ?? keyword, true);
} }
}, [keyword, search, setKeyword]); }, [keyword, search, setKeyword]);
const handleSuggestionPress = useCallback((value: string) => { const handleSuggestionPress = useCallback((value: string) => {
setKeyword(value); setKeyword(value);
Keyboard.dismiss();
search(value, true); search(value, true);
}, [search, setKeyword]); }, [search, setKeyword]);
@@ -110,7 +113,11 @@ export default function SearchScreen() {
}, [hasResults, sort, changeSort, theme.card]); }, [hasResults, sort, changeSort, theme.card]);
const ListEmptyComponent = () => { const ListEmptyComponent = () => {
if (loading) return null; if (loading) return (
<View style={styles.emptyBox}>
<ActivityIndicator color="#00AEEC" size="large" />
</View>
);
if (!keyword.trim()) return null; if (!keyword.trim()) return null;
return ( return (
<View style={styles.emptyBox}> <View style={styles.emptyBox}>

View File

@@ -5,9 +5,9 @@ import {
FlatList, FlatList,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Image,
ActivityIndicator, ActivityIndicator,
} from "react-native"; } from "react-native";
import { Image } from "expo-image";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -32,10 +32,10 @@ export default function VideoDetailScreen() {
const router = useRouter(); const router = useRouter();
const theme = useTheme(); const theme = useTheme();
// 进入视频详情页时立即清除直播小窗
useLayoutEffect(() => { useLayoutEffect(() => {
useLiveStore.getState().clearLive(); useLiveStore.getState().clearLive();
}, []); }, []);
const { const {
video, video,
playData, playData,
@@ -55,6 +55,8 @@ export default function VideoDetailScreen() {
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]); const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [showDownload, setShowDownload] = useState(false); const [showDownload, setShowDownload] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
const [descOverflows, setDescOverflows] = useState(false);
const [uploaderStat, setUploaderStat] = useState<{ const [uploaderStat, setUploaderStat] = useState<{
follower: number; follower: number;
archiveCount: number; archiveCount: number;
@@ -65,9 +67,7 @@ export default function VideoDetailScreen() {
load: loadRelated, load: loadRelated,
} = useRelatedVideos(bvid as string); } = useRelatedVideos(bvid as string);
useEffect(() => { useEffect(() => { loadRelated(); }, []);
loadRelated();
}, []);
useEffect(() => { useEffect(() => {
if (video?.aid) loadComments(); if (video?.aid) loadComments();
@@ -80,9 +80,7 @@ export default function VideoDetailScreen() {
useEffect(() => { useEffect(() => {
if (!video?.owner?.mid) return; if (!video?.owner?.mid) return;
getUploaderStat(video.owner.mid) getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {});
.then(setUploaderStat)
.catch(() => {});
}, [video?.owner?.mid]); }, [video?.owner?.mid]);
return ( return (
@@ -92,25 +90,14 @@ export default function VideoDetailScreen() {
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}> <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} /> <Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity> </TouchableOpacity>
<Text <Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
style={[styles.topTitle, { color: theme.text }]}
numberOfLines={1}
>
{video?.title ?? "视频详情"} {video?.title ?? "视频详情"}
</Text> </Text>
<TouchableOpacity <TouchableOpacity style={styles.miniBtn} onPress={() => setShowDownload(true)}>
style={styles.miniBtn} <Ionicons name="cloud-download-outline" size={22} color={theme.text} />
onPress={() => setShowDownload(true)}
>
<Ionicons
name="cloud-download-outline"
size={22}
color={theme.text}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Video player — fixed 16:9 */}
<VideoPlayer <VideoPlayer
playData={playData} playData={playData}
qualities={qualities} qualities={qualities}
@@ -134,53 +121,20 @@ export default function VideoDetailScreen() {
{/* TabBar */} {/* TabBar */}
{video && ( {video && (
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}> <View style={[styles.tabBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity {(["intro", "comments", "danmaku"] as Tab[]).map((t) => {
style={styles.tabItem} const label =
onPress={() => setTab("intro")} t === "intro" ? "简介"
> : t === "comments" ? `评论${video.stat?.reply ? ` ${formatCount(video.stat.reply)}` : ""}`
<Text : `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`;
style={[ return (
styles.tabLabel, <TouchableOpacity key={t} style={styles.tabItem} onPress={() => setTab(t)}>
{ color: theme.textSub }, <Text style={[styles.tabLabel, { color: theme.textSub }, tab === t && styles.tabActive]}>
tab === "intro" && styles.tabActive, {label}
]}
>
</Text> </Text>
{tab === "intro" && <View style={styles.tabUnderline} />} {tab === t && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("comments")}
>
<Text
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "comments" && styles.tabActive,
]}
>
{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
</Text>
{tab === "comments" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("danmaku")}
>
<Text
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "danmaku" && styles.tabActive,
]}
>
{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity> </TouchableOpacity>
);
})}
</View> </View>
)} )}
@@ -205,128 +159,104 @@ export default function VideoDetailScreen() {
<Image <Image
source={{ uri: proxyImageUrl(video.owner.face) }} source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar} style={styles.avatar}
contentFit="cover"
recyclingKey={String(video.owner.mid)}
/> />
<View style={styles.upInfo}> <View style={styles.upInfo}>
<Text style={[styles.upName, { color: theme.text }]}> <Text style={[styles.upName, { color: theme.text }]}>{video.owner.name}</Text>
{video.owner.name}
</Text>
{uploaderStat && ( {uploaderStat && (
<Text style={styles.upStat}> <Text style={styles.upStat}>
{formatCount(uploaderStat.follower)} ·{" "} {formatCount(uploaderStat.follower)} · {formatCount(uploaderStat.archiveCount)}
{formatCount(uploaderStat.archiveCount)}
</Text> </Text>
)} )}
</View> </View>
<View style={styles.followBtn}> <View style={styles.upStats}>
<Text style={styles.followTxt}></Text> <Ionicons name="play-outline" size={12} color={theme.textSub} />
<Text style={styles.upStatTxt}>{formatCount(video.stat?.view ?? 0)}</Text>
<Text style={styles.upStatDot}>·</Text>
<Ionicons name="heart-outline" size={12} color={theme.textSub} />
<Text style={styles.upStatTxt}>{formatCount(video.stat?.like ?? 0)}</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<View
style={[ <View style={[styles.titleSection, { borderBottomColor: theme.border }]}>
styles.titleSection, <Text style={[styles.title, { color: theme.text }]}>{video.title}</Text>
{ borderBottomColor: theme.border }, {!!video.tname && (
]} <View style={styles.tnameBadge}>
<Text style={styles.tnameText}>{video.tname}</Text>
</View>
)}
{!!video.desc && (
<Text
style={[styles.subTitle, styles.descMeasure]}
onTextLayout={(e) => setDescOverflows(e.nativeEvent.lines.length > 2)}
> >
<Text style={[styles.title, { color: theme.text }]}> {video.desc}
{video.title}
</Text> </Text>
<Text style={[styles.subTitle, { color: theme.text }]}> )}
<Text
style={[styles.subTitle, { color: theme.textSub }]}
numberOfLines={descExpanded ? undefined : 2}
>
{video.desc || "暂无简介"} {video.desc || "暂无简介"}
</Text> </Text>
<View style={styles.statsRow}> {descOverflows && (
<StatBadge icon="play" count={video.stat.view} /> <TouchableOpacity
<StatBadge icon="heart" count={video.stat.like} /> onPress={() => setDescExpanded(e => !e)}
<StatBadge icon="star" count={video.stat.favorite} /> style={styles.expandBtn}
<StatBadge icon="chatbubble" count={video.stat.reply} /> >
</View> <Ionicons name={descExpanded ? "chevron-up" : "chevron-down"} size={13} color="#00AEEC" />
<Text style={styles.expandBtnTxt}>{descExpanded ? "收起" : "展开"}</Text>
</TouchableOpacity>
)}
</View> </View>
{video.ugc_season && ( {video.ugc_season && (
<SeasonSection <SeasonSection
season={video.ugc_season} season={video.ugc_season}
currentBvid={bvid as string} currentBvid={bvid as string}
onEpisodePress={(epBvid) => onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
router.replace(`/video/${epBvid}`)
}
/> />
)} )}
<View <View style={[styles.relatedHeader, { backgroundColor: theme.card }]}>
style={[ <Text style={[styles.relatedHeaderText, { color: theme.text }]}></Text>
styles.relatedHeader,
{
backgroundColor: theme.card,
},
]}
>
<Text
style={[styles.relatedHeaderText, { color: theme.text }]}
>
</Text>
</View> </View>
</> </>
} }
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.relatedCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
styles.relatedCard,
{
backgroundColor: theme.card,
borderBottomColor: theme.border,
},
]}
onPress={() => router.replace(`/video/${item.bvid}` as any)} onPress={() => router.replace(`/video/${item.bvid}` as any)}
activeOpacity={0.85} activeOpacity={0.85}
> >
<View <View style={[styles.relatedThumbWrap, { backgroundColor: theme.card }]}>
style={[
styles.relatedThumbWrap,
{ backgroundColor: theme.card },
]}
>
<Image <Image
source={{ uri: proxyImageUrl(item.pic) }} source={{ uri: proxyImageUrl(item.pic) }}
style={styles.relatedThumb} style={styles.relatedThumb}
resizeMode="cover" contentFit="cover"
recyclingKey={item.bvid}
transition={200}
/> />
<View style={styles.relatedDuration}> <View style={styles.relatedDuration}>
<Text style={styles.relatedDurationText}> <Text style={styles.relatedDurationText}>{formatDuration(item.duration)}</Text>
{formatDuration(item.duration)}
</Text>
</View> </View>
</View> </View>
<View style={styles.relatedInfo}> <View style={styles.relatedInfo}>
<Text <Text style={[styles.relatedTitle, { color: theme.text }]} numberOfLines={2}>
style={[styles.relatedTitle, { color: theme.text }]}
numberOfLines={2}
>
{item.title} {item.title}
</Text> </Text>
<View <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
style={{ <Text style={styles.relatedOwner} numberOfLines={1}>{item.owner?.name ?? ""}</Text>
flexDirection: "row", <Text style={styles.relatedView}>{formatCount(item.stat?.view ?? 0)} </Text>
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={styles.relatedOwner} numberOfLines={1}>
{item.owner?.name ?? ""}
</Text>
<Text style={styles.relatedView}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
)} )}
ListEmptyComponent={ ListEmptyComponent={
relatedLoading ? ( relatedLoading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
} }
ListFooterComponent={ ListFooterComponent={
relatedLoading ? ( relatedLoading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
} }
/> />
)} )}
@@ -337,47 +267,22 @@ export default function VideoDetailScreen() {
data={comments} data={comments}
keyExtractor={(c) => String(c.rpid)} keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />} renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => { onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
if (cmtHasMore && !cmtLoading) loadComments();
}}
onEndReachedThreshold={0.3} onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
ListHeaderComponent={ ListHeaderComponent={
<View <View style={[styles.sortRow, { borderBottomColor: theme.border }]}>
style={[styles.sortRow, { borderBottomColor: theme.border }]} {([2, 0] as const).map((sort) => (
>
<TouchableOpacity <TouchableOpacity
style={[ key={sort}
styles.sortBtn, style={[styles.sortBtn, commentSort === sort && styles.sortBtnActive]}
commentSort === 2 && styles.sortBtnActive, onPress={() => setCommentSort(sort)}
]}
onPress={() => setCommentSort(2)}
> >
<Text <Text style={[styles.sortBtnTxt, commentSort === sort && styles.sortBtnTxtActive]}>
style={[ {sort === 2 ? "热门" : "最新"}
styles.sortBtnTxt,
commentSort === 2 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 0 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(0)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 0 && styles.sortBtnTxtActive,
]}
>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))}
</View> </View>
} }
ListFooterComponent={ ListFooterComponent={
@@ -387,11 +292,7 @@ export default function VideoDetailScreen() {
<Text style={styles.emptyTxt}></Text> <Text style={styles.emptyTxt}></Text>
) : null ) : null
} }
ListEmptyComponent={ ListEmptyComponent={!cmtLoading ? <Text style={styles.emptyTxt}></Text> : null}
!cmtLoading ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
/> />
)} )}
@@ -402,10 +303,7 @@ export default function VideoDetailScreen() {
visible={tab === "danmaku"} visible={tab === "danmaku"}
onToggle={() => {}} onToggle={() => {}}
hideHeader={true} hideHeader={true}
style={[ style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]}
styles.danmakuTab,
tab !== "danmaku" && { display: "none" },
]}
/> />
</> </>
) : null} ) : null}
@@ -413,14 +311,6 @@ export default function VideoDetailScreen() {
); );
} }
function StatBadge({ icon, count }: { icon: string; count: number }) {
return (
<View style={styles.stat}>
<Ionicons name={icon as any} size={14} color="#999" />
<Text style={styles.statText}>{formatCount(count)}</Text>
</View>
);
}
function SeasonSection({ function SeasonSection({
season, season,
@@ -439,26 +329,15 @@ function SeasonSection({
useEffect(() => { useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return; if (currentIndex <= 0 || episodes.length === 0) return;
const t = setTimeout(() => { const t = setTimeout(() => {
listRef.current?.scrollToIndex({ listRef.current?.scrollToIndex({ index: currentIndex, viewPosition: 0.5, animated: false });
index: currentIndex,
viewPosition: 0.5,
animated: false,
});
}, 200); }, 200);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [currentIndex, episodes.length]); }, [currentIndex, episodes.length]);
return ( return (
<View <View style={[styles.seasonBox, { borderTopColor: theme.border, backgroundColor: theme.card }]}>
style={[
styles.seasonBox,
{ borderTopColor: theme.border, backgroundColor: theme.card },
]}
>
<View style={styles.seasonHeader}> <View style={styles.seasonHeader}>
<Text style={[styles.seasonTitle, { color: theme.text }]}> <Text style={[styles.seasonTitle, { color: theme.text }]}> · {season.title}</Text>
· {season.title}
</Text>
<Text style={styles.seasonCount}>{season.ep_count}</Text> <Text style={styles.seasonCount}>{season.ep_count}</Text>
<Ionicons name="chevron-forward" size={14} color="#999" /> <Ionicons name="chevron-forward" size={14} color="#999" />
</View> </View>
@@ -469,11 +348,7 @@ function SeasonSection({
data={episodes} data={episodes}
keyExtractor={(ep) => ep.bvid} keyExtractor={(ep) => ep.bvid}
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
getItemLayout={(_data, index) => ({ getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })}
length: 130,
offset: 12 + index * 130,
index,
})}
onScrollToIndexFailed={() => {}} onScrollToIndexFailed={() => {}}
renderItem={({ item: ep, index }) => { renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid; const isCurrent = ep.bvid === currentBvid;
@@ -491,17 +366,12 @@ function SeasonSection({
<Image <Image
source={{ uri: proxyImageUrl(ep.arc.pic) }} source={{ uri: proxyImageUrl(ep.arc.pic) }}
style={[styles.epThumb, { backgroundColor: theme.card }]} style={[styles.epThumb, { backgroundColor: theme.card }]}
contentFit="cover"
recyclingKey={ep.bvid}
/> />
)} )}
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}> <Text style={[styles.epNum, isCurrent && styles.epNumActive]}>{index + 1}</Text>
{index + 1} <Text style={[styles.epTitle, { color: theme.text }]} numberOfLines={2}>{ep.title}</Text>
</Text>
<Text
style={[styles.epTitle, { color: theme.text }]}
numberOfLines={2}
>
{ep.title}
</Text>
</TouchableOpacity> </TouchableOpacity>
); );
}} }}
@@ -520,53 +390,39 @@ const styles = StyleSheet.create({
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
backBtn: { padding: 4 }, backBtn: { padding: 4 },
topTitle: { topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4 },
flex: 1,
fontSize: 15,
fontWeight: "600",
marginLeft: 4,
},
miniBtn: { padding: 4 }, miniBtn: { padding: 4 },
loader: { marginVertical: 30 }, loader: { marginVertical: 30 },
titleSection: { titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth },
padding: 14, title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 6 },
borderBottomWidth: StyleSheet.hairlineWidth, tnameBadge: {
}, alignSelf: "flex-start",
title: { backgroundColor: "rgba(0,174,236,0.12)",
fontSize: 13, borderRadius: 4,
fontWeight: "600", paddingHorizontal: 6,
lineHeight: 22, paddingVertical: 2,
marginBottom: 8, marginBottom: 8,
}, },
subTitle: { tnameText: { fontSize: 11, color: "#00AEEC" },
fontSize: 10, subTitle: { fontSize: 12, lineHeight: 18, marginBottom: 4 },
marginBottom: 8, descMeasure: { position: "absolute", opacity: 0, left: 0, right: 0 },
}, expandBtn: { flexDirection: "row", alignItems: "center", gap: 3, marginBottom: 8 },
statsRow: { flexDirection: "row", gap: 16 }, expandBtnTxt: { fontSize: 12, color: "#00AEEC" },
stat: { flexDirection: "row", alignItems: "center", gap: 3 },
statText: { fontSize: 12, color: "#999" },
upRow: { upRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
paddingHorizontal: 10, paddingHorizontal: 12,
paddingBottom: 0,
paddingTop: 12, paddingTop: 12,
paddingBottom: 10,
}, },
avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 }, avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10, marginTop: 1 },
upInfo: { flex: 1, justifyContent: "center" }, upInfo: { flex: 1 },
upName: { fontSize: 14, fontWeight: "500" }, upName: { fontSize: 13, fontWeight: "600", lineHeight: 18 },
upStat: { fontSize: 11, color: "#999", marginTop: 2 }, upStat: { fontSize: 11, color: "#999", marginTop: 2, lineHeight: 16 },
followBtn: { upStats: { flexDirection: "row", alignItems: "center", gap: 3, marginTop: 3 },
backgroundColor: "#00AEEC", upStatTxt: { fontSize: 11, color: "#999" },
paddingHorizontal: 10, upStatDot: { fontSize: 11, color: "#ccc" },
paddingVertical: 3, seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 },
borderRadius: 14,
},
followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" },
seasonBox: {
borderTopWidth: StyleSheet.hairlineWidth,
paddingVertical: 10,
},
seasonHeader: { seasonHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -576,34 +432,14 @@ const styles = StyleSheet.create({
}, },
seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" }, seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" },
seasonCount: { fontSize: 12, color: "#999" }, seasonCount: { fontSize: 12, color: "#999" },
epCard: { epCard: { width: 120, borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: "transparent" },
width: 120,
borderRadius: 6,
overflow: "hidden",
borderWidth: 1,
borderColor: "transparent",
},
epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 }, epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 },
epThumb: { width: 120, height: 68 }, epThumb: { width: 120, height: 68 },
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
epNumActive: { color: "#00AEEC", fontWeight: "600" }, epNumActive: { color: "#00AEEC", fontWeight: "600" },
epTitle: { epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 },
fontSize: 12, tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, paddingLeft: 3 },
paddingHorizontal: 6, tabItem: { alignItems: "center", paddingVertical: 12, paddingHorizontal: 12, position: "relative" },
paddingBottom: 6,
lineHeight: 16,
},
tabBar: {
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
paddingLeft: 3,
},
tabItem: {
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 12,
position: "relative",
},
tabLabel: { fontSize: 13 }, tabLabel: { fontSize: 13 },
tabActive: { color: "#00AEEC" }, tabActive: { color: "#00AEEC" },
tabUnderline: { tabUnderline: {
@@ -619,15 +455,8 @@ const styles = StyleSheet.create({
descText: { fontSize: 14, lineHeight: 22 }, descText: { fontSize: 14, lineHeight: 22 },
danmakuTab: { flex: 1 }, danmakuTab: { flex: 1 },
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
relatedHeader: { relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 8 },
paddingLeft: 13, relatedHeaderText: { fontSize: 13, fontWeight: "600" as const },
paddingBottom: 8,
paddingTop: 8,
},
relatedHeaderText: {
fontSize: 13,
fontWeight: "600" as const,
},
relatedCard: { relatedCard: {
flexDirection: "row", flexDirection: "row",
paddingHorizontal: 12, paddingHorizontal: 12,
@@ -654,11 +483,7 @@ const styles = StyleSheet.create({
paddingVertical: 1, paddingVertical: 1,
}, },
relatedDurationText: { color: "#fff", fontSize: 10 }, relatedDurationText: { color: "#fff", fontSize: 10 },
relatedInfo: { relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
flex: 1,
justifyContent: "space-between",
paddingVertical: 2,
},
relatedTitle: { fontSize: 13, lineHeight: 18 }, relatedTitle: { fontSize: 13, lineHeight: 18 },
relatedOwner: { fontSize: 12, color: "#999" }, relatedOwner: { fontSize: 12, color: "#999" },
relatedView: { fontSize: 11, color: "#bbb" }, relatedView: { fontSize: 11, color: "#bbb" },
@@ -671,12 +496,7 @@ const styles = StyleSheet.create({
gap: 8, gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
sortBtn: { sortBtn: { paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, backgroundColor: "#f0f0f0" },
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: "#f0f0f0",
},
sortBtnActive: { backgroundColor: "#00AEEC" }, sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" }, sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const }, sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },

View File

@@ -4,9 +4,9 @@ import {
Text, Text,
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,
Image,
StyleSheet, StyleSheet,
} from "react-native"; } from "react-native";
import { Image } from "expo-image";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { getFollowedLiveRooms } from "../services/bilibili"; import { getFollowedLiveRooms } from "../services/bilibili";
@@ -53,6 +53,8 @@ export function FollowedLiveStrip() {
<Image <Image
source={{ uri: proxyImageUrl(room.face) }} source={{ uri: proxyImageUrl(room.face) }}
style={[styles.avatar, { backgroundColor: theme.card }]} style={[styles.avatar, { backgroundColor: theme.card }]}
contentFit="cover"
recyclingKey={String(room.roomid)}
/> />
<Text <Text
style={[styles.name, { color: theme.text }]} style={[styles.name, { color: theme.text }]}

View File

@@ -2,11 +2,11 @@ import React from "react";
import { import {
View, View,
Text, Text,
Image,
TouchableOpacity, TouchableOpacity,
StyleSheet, StyleSheet,
Dimensions, Dimensions,
} from "react-native"; } from "react-native";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LivePulse } from "./LivePulse"; import { LivePulse } from "./LivePulse";
import type { LiveRoom } from "../services/types"; import type { LiveRoom } from "../services/types";
@@ -41,15 +41,14 @@ export const LiveCard = React.memo(function LiveCard({
<View style={styles.thumbContainer}> <View style={styles.thumbContainer}>
<Image <Image
source={{ uri: proxyImageUrl(item.cover) }} source={{ uri: proxyImageUrl(item.cover) }}
style={[ style={[styles.thumb, { width: cardWidth, height: cardWidth * 0.5625, backgroundColor: theme.card }]}
styles.thumb, contentFit="cover"
{ width: cardWidth, height: cardWidth * 0.5625, backgroundColor: theme.card }, recyclingKey={String(item.roomid)}
]} transition={200}
resizeMode="cover"
/> />
<View style={styles.liveBadge}> <View style={styles.liveBadge}>
{isLivePulse && <LivePulse />} {isLivePulse && <LivePulse />}
<Text style={styles.liveBadgeText}></Text> <Text style={styles.liveBadgeText}></Text>
</View> </View>
<View style={styles.meta}> <View style={styles.meta}>
<Ionicons name="people" size={11} color="#fff" /> <Ionicons name="people" size={11} color="#fff" />
@@ -67,8 +66,13 @@ export const LiveCard = React.memo(function LiveCard({
<Image <Image
source={{ uri: proxyImageUrl(item.face) }} source={{ uri: proxyImageUrl(item.face) }}
style={styles.avatar} style={styles.avatar}
contentFit="cover"
recyclingKey={`face-${item.roomid}`}
/> />
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}> <Text
style={[styles.owner, { color: theme.textSub }]}
numberOfLines={1}
>
{item.uname} {item.uname}
</Text> </Text>
</View> </View>
@@ -103,32 +107,35 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
gap: 2, gap: 2,
}, },
liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" }, liveBadgeText: { color: "#fff", fontSize: 9, fontWeight: "400" },
meta: { meta: {
position: "absolute", position: "absolute",
bottom: 4, bottom: 4,
left: 4, left: 4,
paddingHorizontal: 4,
borderRadius: 5, borderRadius: 5,
paddingHorizontal: 5,
paddingVertical: 1,
backgroundColor: "rgba(0,0,0,0.6)", backgroundColor: "rgba(0,0,0,0.6)",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 2, gap: 2,
}, },
metaText: { fontSize: 10, color: "#fff" }, metaText: { fontSize: 9, color: "#fff" },
areaBadge: { areaBadge: {
position: "absolute", position: "absolute",
bottom: 4, bottom: 4,
right: 4, right: 4,
borderRadius: 5, borderRadius: 5,
paddingHorizontal: 4, paddingHorizontal: 5,
paddingVertical: 1,
backgroundColor: "rgba(0,0,0,0.6)", backgroundColor: "rgba(0,0,0,0.6)",
}, },
areaText: { color: "#fff", fontSize: 10 }, areaText: { color: "#fff", fontSize: 9 },
info: { padding: 6 }, info: { padding: 6 },
title: { title: {
fontSize: 12, fontSize: 12,
lineHeight: 17, lineHeight: 17,
minHeight: 40,
color: "#212121", color: "#212121",
marginBottom: 4, marginBottom: 4,
}, },

View File

@@ -50,8 +50,7 @@ export function LoginModal({ visible, onClose }: Props) {
} }
}, [visible]); }, [visible]);
useEffect(() => { function initQRCode() {
if (!visible) return;
setStatus("loading"); setStatus("loading");
setQrData(null); setQrData(null);
setQrKey(null); setQrKey(null);
@@ -62,7 +61,11 @@ export function LoginModal({ visible, onClose }: Props) {
setStatus("waiting"); setStatus("waiting");
}) })
.catch(() => setStatus("error")); .catch(() => setStatus("error"));
}
useEffect(() => {
if (!visible) return;
initQRCode();
return () => { return () => {
if (pollRef.current) clearInterval(pollRef.current); if (pollRef.current) clearInterval(pollRef.current);
}; };
@@ -187,7 +190,13 @@ export function LoginModal({ visible, onClose }: Props) {
</> </>
)} )}
{status === "error" && ( {status === "error" && (
<Text style={[styles.hint, { color: theme.modalTextSub }]}></Text> <View style={styles.errorBox}>
<Text style={[styles.hint, { color: theme.modalTextSub }]}></Text>
<TouchableOpacity style={styles.retryBtn} onPress={initQRCode}>
<Ionicons name="refresh" size={14} color="#fff" />
<Text style={styles.retryTxt}></Text>
</TouchableOpacity>
</View>
)} )}
<TouchableOpacity style={styles.closeBtn} onPress={onClose}> <TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<Text style={styles.closeTxt}></Text> <Text style={styles.closeTxt}></Text>
@@ -230,7 +239,19 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
hint: { fontSize: 13, color: "#666", marginBottom: 20 }, hint: { fontSize: 13, color: "#666", marginBottom: 12 },
errorBox: { alignItems: "center", marginBottom: 8 },
retryBtn: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#00AEEC",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
marginBottom: 12,
},
retryTxt: { fontSize: 13, color: "#fff", fontWeight: "600" },
closeBtn: { padding: 12 }, closeBtn: { padding: 12 },
closeTxt: { fontSize: 14, color: "#00AEEC" }, closeTxt: { fontSize: 14, color: "#00AEEC" },
}); });

View File

@@ -22,8 +22,11 @@ interface Props {
onPress: () => void; onPress: () => void;
} }
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) { export const VideoCard = React.memo(function VideoCard({
const trafficSaving = useSettingsStore(s => s.trafficSaving); item,
onPress,
}: Props) {
const trafficSaving = useSettingsStore((s) => s.trafficSaving);
const theme = useTheme(); const theme = useTheme();
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -33,7 +36,9 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props)
> >
<View style={styles.thumbContainer}> <View style={styles.thumbContainer}>
<Image <Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }} source={{
uri: coverImageUrl(item.pic, trafficSaving ? "normal" : "hd"),
}}
style={[styles.thumb, { backgroundColor: theme.card }]} style={[styles.thumb, { backgroundColor: theme.card }]}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
@@ -55,7 +60,10 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props)
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}> <Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title} {item.title}
</Text> </Text>
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}> <Text
style={[styles.owner, { color: theme.textSub }]}
numberOfLines={1}
>
{item.owner?.name ?? ""} {item.owner?.name ?? ""}
</Text> </Text>
</View> </View>
@@ -82,15 +90,16 @@ const styles = StyleSheet.create({
bottom: 4, bottom: 4,
right: 4, right: 4,
borderRadius: 5, borderRadius: 5,
paddingHorizontal: 4, paddingHorizontal: 5,
paddingVertical: 1,
backgroundColor: "rgba(0,0,0,0.6)", backgroundColor: "rgba(0,0,0,0.6)",
paddingVertical: 0,
}, },
durationText: { color: "#fff", fontSize: 10 }, durationText: { color: "#fff", fontSize: 9 },
info: { padding: 6 }, info: { padding: 6 },
title: { title: {
fontSize: 12, fontSize: 12,
lineHeight: 17, lineHeight: 17,
minHeight: 40,
color: "#212121", color: "#212121",
marginBottom: 4, marginBottom: 4,
}, },
@@ -99,13 +108,13 @@ const styles = StyleSheet.create({
position: "absolute", position: "absolute",
bottom: 4, bottom: 4,
left: 4, left: 4,
paddingHorizontal: 4,
borderRadius: 5, borderRadius: 5,
paddingHorizontal: 5,
paddingVertical: 1,
backgroundColor: "rgba(0,0,0,0.6)", backgroundColor: "rgba(0,0,0,0.6)",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingVertical: 0,
gap: 2, gap: 2,
}, },
metaText: { fontSize: 10, color: "#fff" }, metaText: { fontSize: 9, color: "#fff" },
}); });

View File

@@ -3,6 +3,8 @@ export interface VideoItem {
aid: number; aid: number;
title: string; title: string;
pic: string; pic: string;
tname?: string;
pubdate?: number;
owner: { owner: {
mid: number; mid: number;
name: string; name: string;