2026-05-05 12:16:35 +08:00
|
|
|
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
2026-03-26 00:06:41 +08:00
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
FlatList,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
ActivityIndicator,
|
2026-05-05 12:16:35 +08:00
|
|
|
|
RefreshControl,
|
|
|
|
|
|
Animated,
|
2026-03-26 00:06:41 +08:00
|
|
|
|
} from 'react-native';
|
2026-05-05 12:16:35 +08:00
|
|
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
2026-03-26 00:06:41 +08:00
|
|
|
|
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';
|
2026-05-05 12:16:35 +08:00
|
|
|
|
import { formatCount, formatDuration, formatTime } from '../../utils/format';
|
2026-03-26 00:06:41 +08:00
|
|
|
|
import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl';
|
|
|
|
|
|
import { useSettingsStore } from '../../store/settingsStore';
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 20;
|
2026-05-05 12:16:35 +08:00
|
|
|
|
const TOPBAR_HEIGHT = 44;
|
|
|
|
|
|
const FADE_START = 80;
|
|
|
|
|
|
const FADE_END = 160;
|
|
|
|
|
|
|
|
|
|
|
|
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<VideoItem>);
|
2026-03-26 00:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
export default function CreatorScreen() {
|
|
|
|
|
|
const { mid: midStr } = useLocalSearchParams<{ mid: string }>();
|
|
|
|
|
|
const mid = Number(midStr);
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const theme = useTheme();
|
2026-05-05 12:16:35 +08:00
|
|
|
|
const insets = useSafeAreaInsets();
|
2026-03-26 00:06:41 +08:00
|
|
|
|
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);
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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',
|
|
|
|
|
|
});
|
2026-03-26 00:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-26 00:06:41 +08:00
|
|
|
|
const hasMore = videos.length < total;
|
|
|
|
|
|
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
2026-03-26 00:06:41 +08:00
|
|
|
|
|
2026-05-05 12:16:35 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<SafeAreaView
|
|
|
|
|
|
style={[styles.safe, { backgroundColor: theme.card }]}
|
|
|
|
|
|
edges={['top', 'left', 'right']}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AnimatedFlatList
|
2026-03-26 00:06:41 +08:00
|
|
|
|
data={videos}
|
|
|
|
|
|
keyExtractor={item => item.bvid}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
|
|
|
|
|
|
onEndReachedThreshold={0.3}
|
|
|
|
|
|
windowSize={7}
|
|
|
|
|
|
maxToRenderPerBatch={6}
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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}
|
|
|
|
|
|
/>
|
2026-03-26 00:06:41 +08:00
|
|
|
|
}
|
2026-05-05 12:16:35 +08:00
|
|
|
|
ListHeaderComponent={HeroHeader}
|
2026-03-26 00:06:41 +08:00
|
|
|
|
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} />
|
2026-05-05 12:16:35 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-03-26 00:06:41 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
ListEmptyComponent={
|
|
|
|
|
|
!loading && !infoLoading ? (
|
|
|
|
|
|
<Text style={[styles.emptyTxt, { color: theme.textSub }]}>暂无视频</Text>
|
|
|
|
|
|
) : null
|
|
|
|
|
|
}
|
|
|
|
|
|
ListFooterComponent={
|
2026-05-05 12:16:35 +08:00
|
|
|
|
<View style={styles.footer}>
|
|
|
|
|
|
{loading && <ActivityIndicator color="#00AEEC" />}
|
|
|
|
|
|
</View>
|
2026-03-26 00:06:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-05-05 12:16:35 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 浮动 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>
|
2026-03-26 00:06:41 +08:00
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
|
safe: { flex: 1 },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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,
|
2026-03-26 00:06:41 +08:00
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
paddingHorizontal: 8,
|
|
|
|
|
|
},
|
|
|
|
|
|
backBtn: { padding: 4, width: 32 },
|
|
|
|
|
|
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
profileContent: {
|
2026-03-26 00:06:41 +08:00
|
|
|
|
alignItems: 'center',
|
2026-05-05 12:16:35 +08:00
|
|
|
|
paddingBottom: 16,
|
2026-03-26 00:06:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
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 },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 4 },
|
2026-03-26 00:06:41 +08:00
|
|
|
|
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 },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
2026-03-26 00:06:41 +08:00
|
|
|
|
metaText: { fontSize: 12 },
|
|
|
|
|
|
emptyTxt: { textAlign: 'center', padding: 40 },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
footer: { height: 48, justifyContent: 'center', alignItems: 'center' },
|
2026-03-26 00:06:41 +08:00
|
|
|
|
});
|