From b0929a80948f073c0261d96de73c1d06cb8bd74b Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 5 May 2026 12:16:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20UP=E4=B8=BB=E9=A1=B5/=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=20UI=20=E4=BC=98=E5=8C=96=20+=20?= =?UTF-8?q?=E5=A4=9A=E5=A4=84=E4=BA=A4=E4=BA=92=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 字段 --- app/creator/[mid].tsx | 247 ++++++++++++----- app/downloads.tsx | 4 +- app/search.tsx | 9 +- app/video/[bvid].tsx | 446 +++++++++---------------------- components/FollowedLiveStrip.tsx | 4 +- components/LiveCard.tsx | 33 ++- components/LoginModal.tsx | 29 +- components/VideoCard.tsx | 29 +- services/types.ts | 2 + 9 files changed, 397 insertions(+), 406 deletions(-) diff --git a/app/creator/[mid].tsx b/app/creator/[mid].tsx index b7a7fe5..5db4c00 100644 --- a/app/creator/[mid].tsx +++ b/app/creator/[mid].tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { View, Text, @@ -6,25 +6,33 @@ import { StyleSheet, TouchableOpacity, ActivityIndicator, + RefreshControl, + Animated, } 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 { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { getUploaderInfo, getUploaderVideos } from '../../services/bilibili'; import type { VideoItem } from '../../services/types'; 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 { useSettingsStore } from '../../store/settingsStore'; const PAGE_SIZE = 20; +const TOPBAR_HEIGHT = 44; +const FADE_START = 80; +const FADE_END = 160; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); export default function CreatorScreen() { const { mid: midStr } = useLocalSearchParams<{ mid: string }>(); const mid = Number(midStr); const router = useRouter(); const theme = useTheme(); + const insets = useSafeAreaInsets(); const trafficSaving = useSettingsStore(s => s.trafficSaving); const [info, setInfo] = useState<{ @@ -35,7 +43,15 @@ export default function CreatorScreen() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); 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(() => { getUploaderInfo(mid) @@ -61,22 +77,81 @@ export default function CreatorScreen() { } }, [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; - return ( - - {/* Top bar */} - - router.back()} style={styles.backBtn}> - - - - {info?.name ?? 'UP主主页'} - - - + const HeroHeader = ( + + {info ? ( + <> + + + + ) : ( + + )} + {infoLoading ? ( + + + + ) : info ? ( + + + {info.name} + {info.sign ? ( + {info.sign} + ) : null} + + + {formatCount(info.follower)} + 粉丝 + + + + {formatCount(info.archiveCount)} + 视频 + + + + 全部视频({total}) + + + ) : null} + + ); - + item.bvid} showsVerticalScrollIndicator={false} @@ -84,39 +159,21 @@ export default function CreatorScreen() { onEndReachedThreshold={0.3} windowSize={7} maxToRenderPerBatch={6} - removeClippedSubviews - ListHeaderComponent={ - infoLoading ? ( - - ) : info ? ( - - - {info.name} - {info.sign ? ( - {info.sign} - ) : null} - - - {formatCount(info.follower)} - 粉丝 - - - - {formatCount(info.archiveCount)} - 视频 - - - - 全部视频({total}) - - - ) : null + onScroll={Animated.event( + [{ nativeEvent: { contentOffset: { y: scrollY } } }], + { useNativeDriver: true }, + )} + scrollEventThrottle={16} + refreshControl={ + } + ListHeaderComponent={HeroHeader} renderItem={({ item }) => ( - {formatCount(item.stat.view)} + {formatCount(item.stat?.view ?? 0)} + {!!item.pubdate && ( + · {formatTime(item.pubdate)} + )} @@ -152,30 +212,93 @@ export default function CreatorScreen() { ) : null } ListFooterComponent={ - loading ? : null + + {loading && } + } /> + + {/* 浮动 topBar:背景透明 → 不透明,跟随滚动 */} + + + + + router.back()} style={styles.backBtn}> + + + + {info?.name ?? 'UP主主页'} + + + + ); } const styles = StyleSheet.create({ 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', alignItems: 'center', paddingHorizontal: 8, - paddingVertical: 8, - borderBottomWidth: StyleSheet.hairlineWidth, }, backBtn: { padding: 4, width: 32 }, topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' }, - profileCard: { + profileContent: { alignItems: 'center', - paddingTop: 24, - paddingBottom: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - marginBottom: 4, + paddingBottom: 16, }, avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 }, name: { fontSize: 18, fontWeight: '700', marginBottom: 6 }, @@ -185,8 +308,7 @@ const styles = StyleSheet.create({ statNum: { fontSize: 18, fontWeight: '700' }, statLabel: { fontSize: 12, marginTop: 2 }, statDivider: { width: 1, height: 28 }, - videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 8 }, - loader: { marginVertical: 24 }, + videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 4 }, videoRow: { flexDirection: 'row', paddingHorizontal: 12, @@ -203,7 +325,8 @@ const styles = StyleSheet.create({ durationText: { color: '#fff', fontSize: 10 }, videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 }, videoTitle: { fontSize: 13, lineHeight: 18 }, - videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 3 }, + videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 4 }, metaText: { fontSize: 12 }, emptyTxt: { textAlign: 'center', padding: 40 }, + footer: { height: 48, justifyContent: 'center', alignItems: 'center' }, }); diff --git a/app/downloads.tsx b/app/downloads.tsx index dd4e86d..e062626 100644 --- a/app/downloads.tsx +++ b/app/downloads.tsx @@ -5,11 +5,11 @@ import { SectionList, StyleSheet, TouchableOpacity, - Image, Modal, StatusBar, Alert, } from 'react-native'; +import { Image } from 'expo-image'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; @@ -171,7 +171,7 @@ function DownloadRow({ const rowContent = ( - + {task.title} diff --git a/app/search.tsx b/app/search.tsx index e076e00..18e7765 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -8,6 +8,7 @@ import { FlatList, ActivityIndicator, ScrollView, + Keyboard, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -43,12 +44,14 @@ export default function SearchScreen() { const term = (kw ?? keyword).trim(); if (term) { if (kw) setKeyword(kw); + Keyboard.dismiss(); search(kw ?? keyword, true); } }, [keyword, search, setKeyword]); const handleSuggestionPress = useCallback((value: string) => { setKeyword(value); + Keyboard.dismiss(); search(value, true); }, [search, setKeyword]); @@ -110,7 +113,11 @@ export default function SearchScreen() { }, [hasResults, sort, changeSort, theme.card]); const ListEmptyComponent = () => { - if (loading) return null; + if (loading) return ( + + + + ); if (!keyword.trim()) return null; return ( diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 2116256..8dcf690 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -5,9 +5,9 @@ import { FlatList, StyleSheet, TouchableOpacity, - Image, ActivityIndicator, } from "react-native"; +import { Image } from "expo-image"; import { SafeAreaView } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; @@ -32,10 +32,10 @@ export default function VideoDetailScreen() { const router = useRouter(); const theme = useTheme(); - // 进入视频详情页时立即清除直播小窗 useLayoutEffect(() => { useLiveStore.getState().clearLive(); }, []); + const { video, playData, @@ -55,6 +55,8 @@ export default function VideoDetailScreen() { const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDownload, setShowDownload] = useState(false); + const [descExpanded, setDescExpanded] = useState(false); + const [descOverflows, setDescOverflows] = useState(false); const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number; @@ -65,9 +67,7 @@ export default function VideoDetailScreen() { load: loadRelated, } = useRelatedVideos(bvid as string); - useEffect(() => { - loadRelated(); - }, []); + useEffect(() => { loadRelated(); }, []); useEffect(() => { if (video?.aid) loadComments(); @@ -80,9 +80,7 @@ export default function VideoDetailScreen() { useEffect(() => { if (!video?.owner?.mid) return; - getUploaderStat(video.owner.mid) - .then(setUploaderStat) - .catch(() => {}); + getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {}); }, [video?.owner?.mid]); return ( @@ -92,25 +90,14 @@ export default function VideoDetailScreen() { router.back()} style={styles.backBtn}> - + {video?.title ?? "视频详情"} - setShowDownload(true)} - > - + setShowDownload(true)}> + - {/* Video player — fixed 16:9 */} - setTab("intro")} - > - - 简介 - - {tab === "intro" && } - - setTab("comments")} - > - - 评论 - {video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""} - - {tab === "comments" && } - - setTab("danmaku")} - > - - 弹幕 - {danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""} - - {tab === "danmaku" && } - + {(["intro", "comments", "danmaku"] as Tab[]).map((t) => { + const label = + t === "intro" ? "简介" + : t === "comments" ? `评论${video.stat?.reply ? ` ${formatCount(video.stat.reply)}` : ""}` + : `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`; + return ( + setTab(t)}> + + {label} + + {tab === t && } + + ); + })} )} @@ -205,128 +159,104 @@ export default function VideoDetailScreen() { - - {video.owner.name} - + {video.owner.name} {uploaderStat && ( - {formatCount(uploaderStat.follower)}粉丝 ·{" "} - {formatCount(uploaderStat.archiveCount)}视频 + {formatCount(uploaderStat.follower)}粉丝 · {formatCount(uploaderStat.archiveCount)}视频 )} - - 查看主页 + + + {formatCount(video.stat?.view ?? 0)} + · + + {formatCount(video.stat?.like ?? 0)} - - - {video.title} - - + + + {video.title} + {!!video.tname && ( + + {video.tname} + + )} + {!!video.desc && ( + setDescOverflows(e.nativeEvent.lines.length > 2)} + > + {video.desc} + + )} + {video.desc || "暂无简介"} - - - - - - + {descOverflows && ( + setDescExpanded(e => !e)} + style={styles.expandBtn} + > + + {descExpanded ? "收起" : "展开"} + + )} + {video.ugc_season && ( - router.replace(`/video/${epBvid}`) - } + onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)} /> )} - - - 推荐视频 - + + 推荐视频 } renderItem={({ item }) => ( router.replace(`/video/${item.bvid}` as any)} activeOpacity={0.85} > - + - - {formatDuration(item.duration)} - + {formatDuration(item.duration)} - + {item.title} - - - {item.owner?.name ?? ""} - - - {formatCount(item.stat?.view ?? 0)} 播放 - + + {item.owner?.name ?? ""} + {formatCount(item.stat?.view ?? 0)} 播放 )} ListEmptyComponent={ - relatedLoading ? ( - - ) : null + relatedLoading ? : null } ListFooterComponent={ - relatedLoading ? ( - - ) : null + relatedLoading ? : null } /> )} @@ -337,47 +267,22 @@ export default function VideoDetailScreen() { data={comments} keyExtractor={(c) => String(c.rpid)} renderItem={({ item }) => } - onEndReached={() => { - if (cmtHasMore && !cmtLoading) loadComments(); - }} + onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }} onEndReachedThreshold={0.3} showsVerticalScrollIndicator={false} ListHeaderComponent={ - - setCommentSort(2)} - > - + {([2, 0] as const).map((sort) => ( + setCommentSort(sort)} > - 热门 - - - setCommentSort(0)} - > - - 最新 - - + + {sort === 2 ? "热门" : "最新"} + + + ))} } ListFooterComponent={ @@ -387,11 +292,7 @@ export default function VideoDetailScreen() { 已加载全部评论 ) : null } - ListEmptyComponent={ - !cmtLoading ? ( - 暂无评论 - ) : null - } + ListEmptyComponent={!cmtLoading ? 暂无评论 : null} /> )} @@ -402,10 +303,7 @@ export default function VideoDetailScreen() { visible={tab === "danmaku"} onToggle={() => {}} hideHeader={true} - style={[ - styles.danmakuTab, - tab !== "danmaku" && { display: "none" }, - ]} + style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]} /> ) : null} @@ -413,14 +311,6 @@ export default function VideoDetailScreen() { ); } -function StatBadge({ icon, count }: { icon: string; count: number }) { - return ( - - - {formatCount(count)} - - ); -} function SeasonSection({ season, @@ -439,26 +329,15 @@ function SeasonSection({ useEffect(() => { if (currentIndex <= 0 || episodes.length === 0) return; const t = setTimeout(() => { - listRef.current?.scrollToIndex({ - index: currentIndex, - viewPosition: 0.5, - animated: false, - }); + listRef.current?.scrollToIndex({ index: currentIndex, viewPosition: 0.5, animated: false }); }, 200); return () => clearTimeout(t); }, [currentIndex, episodes.length]); return ( - + - - 合集 · {season.title} - + 合集 · {season.title} {season.ep_count}个视频 @@ -469,11 +348,7 @@ function SeasonSection({ data={episodes} keyExtractor={(ep) => ep.bvid} contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} - getItemLayout={(_data, index) => ({ - length: 130, - offset: 12 + index * 130, - index, - })} + getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })} onScrollToIndexFailed={() => {}} renderItem={({ item: ep, index }) => { const isCurrent = ep.bvid === currentBvid; @@ -491,17 +366,12 @@ function SeasonSection({ )} - - 第{index + 1}集 - - - {ep.title} - + 第{index + 1}集 + {ep.title} ); }} @@ -520,53 +390,39 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, }, backBtn: { padding: 4 }, - topTitle: { - flex: 1, - fontSize: 15, - fontWeight: "600", - marginLeft: 4, - }, + topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4 }, miniBtn: { padding: 4 }, loader: { marginVertical: 30 }, - titleSection: { - padding: 14, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - title: { - fontSize: 13, - fontWeight: "600", - lineHeight: 22, + titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth }, + title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 6 }, + tnameBadge: { + alignSelf: "flex-start", + backgroundColor: "rgba(0,174,236,0.12)", + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, marginBottom: 8, }, - subTitle: { - fontSize: 10, - marginBottom: 8, - }, - statsRow: { flexDirection: "row", gap: 16 }, - stat: { flexDirection: "row", alignItems: "center", gap: 3 }, - statText: { fontSize: 12, color: "#999" }, + tnameText: { fontSize: 11, color: "#00AEEC" }, + subTitle: { fontSize: 12, lineHeight: 18, marginBottom: 4 }, + descMeasure: { position: "absolute", opacity: 0, left: 0, right: 0 }, + expandBtn: { flexDirection: "row", alignItems: "center", gap: 3, marginBottom: 8 }, + expandBtnTxt: { fontSize: 12, color: "#00AEEC" }, upRow: { flexDirection: "row", - alignItems: "center", - paddingHorizontal: 10, - paddingBottom: 0, + alignItems: "flex-start", + paddingHorizontal: 12, paddingTop: 12, + paddingBottom: 10, }, - avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 }, - upInfo: { flex: 1, justifyContent: "center" }, - upName: { fontSize: 14, fontWeight: "500" }, - upStat: { fontSize: 11, color: "#999", marginTop: 2 }, - followBtn: { - backgroundColor: "#00AEEC", - paddingHorizontal: 10, - paddingVertical: 3, - borderRadius: 14, - }, - followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" }, - seasonBox: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingVertical: 10, - }, + avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10, marginTop: 1 }, + upInfo: { flex: 1 }, + upName: { fontSize: 13, fontWeight: "600", lineHeight: 18 }, + upStat: { fontSize: 11, color: "#999", marginTop: 2, lineHeight: 16 }, + upStats: { flexDirection: "row", alignItems: "center", gap: 3, marginTop: 3 }, + upStatTxt: { fontSize: 11, color: "#999" }, + upStatDot: { fontSize: 11, color: "#ccc" }, + seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 }, seasonHeader: { flexDirection: "row", alignItems: "center", @@ -576,34 +432,14 @@ const styles = StyleSheet.create({ }, seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" }, seasonCount: { fontSize: 12, color: "#999" }, - epCard: { - width: 120, - borderRadius: 6, - overflow: "hidden", - borderWidth: 1, - borderColor: "transparent", - }, + epCard: { width: 120, borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: "transparent" }, epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 }, epThumb: { width: 120, height: 68 }, epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, epNumActive: { color: "#00AEEC", fontWeight: "600" }, - epTitle: { - fontSize: 12, - paddingHorizontal: 6, - paddingBottom: 6, - lineHeight: 16, - }, - tabBar: { - flexDirection: "row", - borderBottomWidth: StyleSheet.hairlineWidth, - paddingLeft: 3, - }, - tabItem: { - alignItems: "center", - paddingVertical: 12, - paddingHorizontal: 12, - position: "relative", - }, + epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 }, + tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, paddingLeft: 3 }, + tabItem: { alignItems: "center", paddingVertical: 12, paddingHorizontal: 12, position: "relative" }, tabLabel: { fontSize: 13 }, tabActive: { color: "#00AEEC" }, tabUnderline: { @@ -619,15 +455,8 @@ const styles = StyleSheet.create({ descText: { fontSize: 14, lineHeight: 22 }, danmakuTab: { flex: 1 }, emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, - relatedHeader: { - paddingLeft: 13, - paddingBottom: 8, - paddingTop: 8, - }, - relatedHeaderText: { - fontSize: 13, - fontWeight: "600" as const, - }, + relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 8 }, + relatedHeaderText: { fontSize: 13, fontWeight: "600" as const }, relatedCard: { flexDirection: "row", paddingHorizontal: 12, @@ -654,11 +483,7 @@ const styles = StyleSheet.create({ paddingVertical: 1, }, relatedDurationText: { color: "#fff", fontSize: 10 }, - relatedInfo: { - flex: 1, - justifyContent: "space-between", - paddingVertical: 2, - }, + relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 }, relatedTitle: { fontSize: 13, lineHeight: 18 }, relatedOwner: { fontSize: 12, color: "#999" }, relatedView: { fontSize: 11, color: "#bbb" }, @@ -671,12 +496,7 @@ const styles = StyleSheet.create({ gap: 8, borderBottomWidth: StyleSheet.hairlineWidth, }, - sortBtn: { - paddingHorizontal: 10, - paddingVertical: 2, - borderRadius: 16, - backgroundColor: "#f0f0f0", - }, + sortBtn: { paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, backgroundColor: "#f0f0f0" }, sortBtnActive: { backgroundColor: "#00AEEC" }, sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" }, sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const }, diff --git a/components/FollowedLiveStrip.tsx b/components/FollowedLiveStrip.tsx index 745fd37..b9c820f 100644 --- a/components/FollowedLiveStrip.tsx +++ b/components/FollowedLiveStrip.tsx @@ -4,9 +4,9 @@ import { Text, ScrollView, TouchableOpacity, - Image, StyleSheet, } from "react-native"; +import { Image } from "expo-image"; import { useRouter } from "expo-router"; import { useAuthStore } from "../store/authStore"; import { getFollowedLiveRooms } from "../services/bilibili"; @@ -53,6 +53,8 @@ export function FollowedLiveStrip() { {isLivePulse && } - 直播中 + 直播 @@ -67,8 +66,13 @@ export const LiveCard = React.memo(function LiveCard({ - + {item.uname} @@ -103,32 +107,35 @@ const styles = StyleSheet.create({ alignItems: "center", gap: 2, }, - liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" }, + liveBadgeText: { color: "#fff", fontSize: 9, fontWeight: "400" }, meta: { position: "absolute", bottom: 4, left: 4, - paddingHorizontal: 4, borderRadius: 5, + paddingHorizontal: 5, + paddingVertical: 1, backgroundColor: "rgba(0,0,0,0.6)", flexDirection: "row", alignItems: "center", gap: 2, }, - metaText: { fontSize: 10, color: "#fff" }, + metaText: { fontSize: 9, color: "#fff" }, areaBadge: { position: "absolute", bottom: 4, right: 4, borderRadius: 5, - paddingHorizontal: 4, + paddingHorizontal: 5, + paddingVertical: 1, backgroundColor: "rgba(0,0,0,0.6)", }, - areaText: { color: "#fff", fontSize: 10 }, + areaText: { color: "#fff", fontSize: 9 }, info: { padding: 6 }, title: { fontSize: 12, lineHeight: 17, + minHeight: 40, color: "#212121", marginBottom: 4, }, diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 115e021..e77b1a7 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -50,8 +50,7 @@ export function LoginModal({ visible, onClose }: Props) { } }, [visible]); - useEffect(() => { - if (!visible) return; + function initQRCode() { setStatus("loading"); setQrData(null); setQrKey(null); @@ -62,7 +61,11 @@ export function LoginModal({ visible, onClose }: Props) { setStatus("waiting"); }) .catch(() => setStatus("error")); + } + useEffect(() => { + if (!visible) return; + initQRCode(); return () => { if (pollRef.current) clearInterval(pollRef.current); }; @@ -187,7 +190,13 @@ export function LoginModal({ visible, onClose }: Props) { )} {status === "error" && ( - 二维码已过期,请关闭重试 + + 二维码已过期 + + + 重新获取 + + )} 关闭 @@ -230,7 +239,19 @@ const styles = StyleSheet.create({ alignItems: "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 }, closeTxt: { fontSize: 14, color: "#00AEEC" }, }); diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx index 21b0cbc..a4ef06a 100644 --- a/components/VideoCard.tsx +++ b/components/VideoCard.tsx @@ -22,8 +22,11 @@ interface Props { onPress: () => void; } -export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) { - const trafficSaving = useSettingsStore(s => s.trafficSaving); +export const VideoCard = React.memo(function VideoCard({ + item, + onPress, +}: Props) { + const trafficSaving = useSettingsStore((s) => s.trafficSaving); const theme = useTheme(); return ( {item.title} - + {item.owner?.name ?? ""} @@ -82,15 +90,16 @@ const styles = StyleSheet.create({ bottom: 4, right: 4, borderRadius: 5, - paddingHorizontal: 4, + paddingHorizontal: 5, + paddingVertical: 1, backgroundColor: "rgba(0,0,0,0.6)", - paddingVertical: 0, }, - durationText: { color: "#fff", fontSize: 10 }, + durationText: { color: "#fff", fontSize: 9 }, info: { padding: 6 }, title: { fontSize: 12, lineHeight: 17, + minHeight: 40, color: "#212121", marginBottom: 4, }, @@ -99,13 +108,13 @@ const styles = StyleSheet.create({ position: "absolute", bottom: 4, left: 4, - paddingHorizontal: 4, borderRadius: 5, + paddingHorizontal: 5, + paddingVertical: 1, backgroundColor: "rgba(0,0,0,0.6)", flexDirection: "row", alignItems: "center", - paddingVertical: 0, gap: 2, }, - metaText: { fontSize: 10, color: "#fff" }, + metaText: { fontSize: 9, color: "#fff" }, }); diff --git a/services/types.ts b/services/types.ts index f32c456..77aa95b 100644 --- a/services/types.ts +++ b/services/types.ts @@ -3,6 +3,8 @@ export interface VideoItem { aid: number; title: string; pic: string; + tname?: string; + pubdate?: number; owner: { mid: number; name: string;