mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
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:
@@ -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,22 +77,81 @@ 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>
|
</>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
<FlatList
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={[styles.safe, { backgroundColor: theme.card }]}
|
||||||
|
edges={['top', 'left', 'right']}
|
||||||
|
>
|
||||||
|
<AnimatedFlatList
|
||||||
data={videos}
|
data={videos}
|
||||||
keyExtractor={item => item.bvid}
|
keyExtractor={item => item.bvid}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -84,39 +159,21 @@ export default function CreatorScreen() {
|
|||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
windowSize={7}
|
windowSize={7}
|
||||||
maxToRenderPerBatch={6}
|
maxToRenderPerBatch={6}
|
||||||
removeClippedSubviews
|
onScroll={Animated.event(
|
||||||
ListHeaderComponent={
|
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
|
||||||
infoLoading ? (
|
{ useNativeDriver: true },
|
||||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
)}
|
||||||
) : info ? (
|
scrollEventThrottle={16}
|
||||||
<View style={[styles.profileCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
refreshControl={
|
||||||
<Image
|
<RefreshControl
|
||||||
source={{ uri: proxyImageUrl(info.face) }}
|
refreshing={refreshing}
|
||||||
style={styles.avatar}
|
onRefresh={handleRefresh}
|
||||||
contentFit="cover"
|
tintColor="#00AEEC"
|
||||||
recyclingKey={String(mid)}
|
colors={["#00AEEC"]}
|
||||||
/>
|
progressViewOffset={insets.top + TOPBAR_HEIGHT}
|
||||||
<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
|
|
||||||
}
|
}
|
||||||
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
>
|
{tab === t && <View style={styles.tabUnderline} />}
|
||||||
简介
|
</TouchableOpacity>
|
||||||
</Text>
|
);
|
||||||
{tab === "intro" && <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>
|
|
||||||
</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>
|
||||||
<Text style={[styles.title, { color: theme.text }]}>
|
</View>
|
||||||
{video.title}
|
)}
|
||||||
</Text>
|
{!!video.desc && (
|
||||||
<Text style={[styles.subTitle, { color: theme.text }]}>
|
<Text
|
||||||
|
style={[styles.subTitle, styles.descMeasure]}
|
||||||
|
onTextLayout={(e) => setDescOverflows(e.nativeEvent.lines.length > 2)}
|
||||||
|
>
|
||||||
|
{video.desc}
|
||||||
|
</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
|
key={sort}
|
||||||
style={[
|
style={[styles.sortBtn, commentSort === sort && styles.sortBtnActive]}
|
||||||
styles.sortBtn,
|
onPress={() => setCommentSort(sort)}
|
||||||
commentSort === 2 && styles.sortBtnActive,
|
|
||||||
]}
|
|
||||||
onPress={() => setCommentSort(2)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.sortBtnTxt,
|
|
||||||
commentSort === 2 && styles.sortBtnTxtActive,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
热门
|
<Text style={[styles.sortBtnTxt, commentSort === sort && styles.sortBtnTxtActive]}>
|
||||||
</Text>
|
{sort === 2 ? "热门" : "最新"}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
<TouchableOpacity
|
</TouchableOpacity>
|
||||||
style={[
|
))}
|
||||||
styles.sortBtn,
|
|
||||||
commentSort === 0 && styles.sortBtnActive,
|
|
||||||
]}
|
|
||||||
onPress={() => setCommentSort(0)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.sortBtnTxt,
|
|
||||||
commentSort === 0 && styles.sortBtnTxtActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
最新
|
|
||||||
</Text>
|
|
||||||
</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 },
|
||||||
|
|||||||
@@ -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 }]}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user