Files
JKVideo/app/creator/[mid].tsx
Developer b0929a8094 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 字段
2026-05-05 12:17:45 +08:00

333 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
Animated,
} from 'react-native';
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, 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<VideoItem>);
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<{
name: string; face: string; sign: string; follower: number; archiveCount: number;
} | null>(null);
const [videos, setVideos] = useState<VideoItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [infoLoading, setInfoLoading] = useState(true);
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)
.then(setInfo)
.catch(() => {})
.finally(() => setInfoLoading(false));
loadVideos(1, true);
}, [mid]);
const loadVideos = useCallback(async (pn: number, reset = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const { videos: newVideos, total: t } = await getUploaderVideos(mid, pn, PAGE_SIZE);
setTotal(t);
setVideos(prev => reset ? newVideos : [...prev, ...newVideos]);
setPage(pn);
} catch {}
finally {
loadingRef.current = false;
setLoading(false);
}
}, [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 HeroHeader = (
<View style={[styles.hero, { borderBottomColor: theme.border }]}>
{info ? (
<>
<Image
source={{ uri: proxyImageUrl(info.face) }}
style={styles.heroBg}
contentFit="cover"
blurRadius={20}
/>
<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>
) : info ? (
<View style={[styles.profileContent, { paddingTop: TOPBAR_HEIGHT + 12 }]}>
<Image
source={{ uri: proxyImageUrl(info.face) }}
style={styles.avatar}
contentFit="cover"
recyclingKey={String(mid)}
/>
<Text style={[styles.name, { color: theme.text }]}>{info.name}</Text>
{info.sign ? (
<Text style={[styles.sign, { color: theme.textSub }]} numberOfLines={2}>{info.sign}</Text>
) : null}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.follower)}</Text>
<Text style={[styles.statLabel, { color: theme.textSub }]}></Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.border }]} />
<View style={styles.statItem}>
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.archiveCount)}</Text>
<Text style={[styles.statLabel, { color: theme.textSub }]}></Text>
</View>
</View>
<Text style={[styles.videoListHeader, { color: theme.textSub }]}>
{total}
</Text>
</View>
) : 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 }) => (
<TouchableOpacity
style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
onPress={() => router.push(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
<View style={styles.thumbWrap}>
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={styles.thumb}
contentFit="cover"
recyclingKey={item.bvid}
transition={200}
/>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
</View>
</View>
<View style={styles.videoInfo}>
<Text style={[styles.videoTitle, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<View style={styles.videoMeta}>
<Ionicons name="play" size={11} color={theme.textSub} />
<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>
</TouchableOpacity>
)}
ListEmptyComponent={
!loading && !infoLoading ? (
<Text style={[styles.emptyTxt, { color: theme.textSub }]}></Text>
) : null
}
ListFooterComponent={
<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>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
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,
},
backBtn: { padding: 4, width: 32 },
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
profileContent: {
alignItems: 'center',
paddingBottom: 16,
},
avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 },
name: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
sign: { fontSize: 13, textAlign: 'center', paddingHorizontal: 24, marginBottom: 12, lineHeight: 19 },
statsRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
statItem: { alignItems: 'center', paddingHorizontal: 24 },
statNum: { fontSize: 18, fontWeight: '700' },
statLabel: { fontSize: 12, marginTop: 2 },
statDivider: { width: 1, height: 28 },
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 4 },
videoRow: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
},
thumbWrap: { width: 120, height: 68, borderRadius: 4, overflow: 'hidden', flexShrink: 0, position: 'relative' },
thumb: { width: 120, height: 68 },
durationBadge: {
position: 'absolute', bottom: 3, right: 3,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1,
},
durationText: { color: '#fff', fontSize: 10 },
videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 },
videoTitle: { fontSize: 13, lineHeight: 18 },
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 4 },
metaText: { fontSize: 12 },
emptyTxt: { textAlign: 'center', padding: 40 },
footer: { height: 48, justifyContent: 'center', alignItems: 'center' },
});