This commit is contained in:
Developer
2026-03-10 19:04:18 +08:00
parent 18eebfb0d2
commit cf20b016ff
18 changed files with 1106 additions and 123 deletions

View File

@@ -1,9 +1,10 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { View } from 'react-native';
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
import { MiniPlayer } from '../components/MiniPlayer';
export default function RootLayout() {
const restore = useAuthStore(s => s.restore);
@@ -15,28 +16,10 @@ export default function RootLayout() {
return (
<SafeAreaProvider>
<StatusBar style="dark" />
<Tabs
screenOptions={{
tabBarActiveTintColor: '#00AEEC',
tabBarInactiveTintColor: '#999',
tabBarStyle: { borderTopColor: '#eee' },
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: '首页',
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="video"
options={{ href: null }}
/>
</Tabs>
<View style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }} />
<MiniPlayer />
</View>
</SafeAreaProvider>
);
}

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
View, FlatList, StyleSheet,
Text, TouchableOpacity, ActivityIndicator, Dimensions
View, StyleSheet,
Text, TouchableOpacity, ActivityIndicator, Animated, Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { VideoCard } from '../components/VideoCard';
@@ -12,11 +12,26 @@ import { useVideoList } from '../hooks/useVideoList';
import { useAuthStore } from '../store/authStore';
import type { VideoItem } from '../services/types';
const HEADER_H = 44;
const TAB_H = 38;
const NAV_H = HEADER_H + TAB_H;
export default function HomeScreen() {
const router = useRouter();
const { videos, loading, refreshing, load, refresh } = useVideoList();
const { isLoggedIn, logout } = useAuthStore();
const { isLoggedIn, face, logout } = useAuthStore();
const [showLogin, setShowLogin] = useState(false);
const insets = useSafeAreaInsets();
const scrollY = useRef(new Animated.Value(0)).current;
const clampedScroll = useRef(
Animated.diffClamp(scrollY, 0, NAV_H)
).current;
const headerTranslate = clampedScroll.interpolate({
inputRange: [0, NAV_H],
outputRange: [0, -NAV_H],
extrapolate: 'clamp',
});
useEffect(() => { load(); }, []);
@@ -30,41 +45,59 @@ export default function HomeScreen() {
);
return (
<SafeAreaView style={styles.safe}>
<View style={styles.header}>
<Text style={styles.logo}></Text>
<View style={styles.headerRight}>
<TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerBtn}
onPress={() => isLoggedIn ? logout() : setShowLogin(true)}
>
<Ionicons name={isLoggedIn ? 'person' : 'person-outline'} size={22} color="#00AEEC" />
</TouchableOpacity>
</View>
</View>
<View style={styles.tabRow}>
<Text style={styles.tabActive}></Text>
<View style={styles.tabUnderline} />
</View>
<FlatList
<SafeAreaView style={styles.safe} edges={['left', 'right']}>
<Animated.FlatList
style={styles.listContainer}
data={videos}
keyExtractor={(item, index) => `${item.bvid}-${index}`}
numColumns={2}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.list}
contentContainerStyle={{ paddingTop: insets.top + NAV_H + 8, paddingBottom: insets.bottom + 16 }}
renderItem={renderItem}
onRefresh={refresh}
refreshing={refreshing}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
ListFooterComponent={loading ? <ActivityIndicator style={styles.footer} color="#00AEEC" /> : null}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
/>
{/* 绝对定位导航栏paddingTop 手动适配刘海/状态栏 */}
<Animated.View
style={[
styles.navBar,
{ paddingTop: insets.top, transform: [{ translateY: headerTranslate }] },
]}
>
<View style={styles.header}>
<Text style={styles.logo}></Text>
<View style={styles.headerRight}>
<TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerBtn}
onPress={() => isLoggedIn ? logout() : setShowLogin(true)}
>
{isLoggedIn && face ? (
<Image source={{ uri: face }} style={styles.userAvatar} />
) : (
<Ionicons name={isLoggedIn ? 'person' : 'person-outline'} size={22} color="#00AEEC" />
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.tabRow}>
<Text style={styles.tabActive}></Text>
<View style={styles.tabUnderline} />
</View>
</Animated.View>
<LoginModal visible={showLogin} onClose={() => setShowLogin(false)} />
</SafeAreaView>
);
@@ -72,15 +105,54 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f4f4f4' },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
listContainer: { flex: 1 },
navBar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
backgroundColor: '#fff',
overflow: 'hidden',
// 安卓投影
elevation: 2,
// iOS 投影
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
},
header: {
height: HEADER_H,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
logo: { fontSize: 20, fontWeight: '800', color: '#00AEEC', letterSpacing: -0.5 },
headerRight: { flexDirection: 'row', gap: 8 },
headerBtn: { padding: 4 },
tabRow: { backgroundColor: '#fff', paddingHorizontal: 16, paddingBottom: 0, flexDirection: 'row', alignItems: 'center', position: 'relative' },
tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC', paddingVertical: 10 },
tabUnderline: { position: 'absolute', bottom: 0, left: 16, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 },
headerBtn: { padding: 6 },
userAvatar: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#eee' },
tabRow: {
height: TAB_H,
backgroundColor: '#fff',
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
},
tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC' },
tabUnderline: {
position: 'absolute',
bottom: 0,
left: 16,
width: 24,
height: 2,
backgroundColor: '#00AEEC',
borderRadius: 1,
},
row: { paddingHorizontal: 8 },
list: { paddingTop: 8, paddingBottom: 80 },
leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 },
footer: { marginVertical: 16 },

View File

@@ -10,6 +10,7 @@ import { VideoPlayer } from '../../components/VideoPlayer';
import { CommentItem } from '../../components/CommentItem';
import { useVideoDetail } from '../../hooks/useVideoDetail';
import { useComments } from '../../hooks/useComments';
import { useVideoStore } from '../../store/videoStore';
import { formatCount } from '../../utils/format';
type Tab = 'intro' | 'comments';
@@ -17,14 +18,26 @@ type Tab = 'intro' | 'comments';
export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
const router = useRouter();
const { video, streamUrl, loading: videoLoading } = useVideoDetail(bvid as string);
const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string);
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
const [tab, setTab] = useState<Tab>('comments');
const { setVideo, clearVideo } = useVideoStore();
useEffect(() => {
clearVideo();
}, [bvid]);
useEffect(() => {
if (video?.aid) loadComments();
}, [video?.aid]);
function handleMiniPlayer() {
if (video) {
setVideo(bvid as string, video.title, video.pic);
router.back();
}
}
return (
<SafeAreaView style={styles.safe}>
<View style={styles.topBar}>
@@ -32,9 +45,18 @@ export default function VideoDetailScreen() {
<Ionicons name="chevron-back" size={24} color="#212121" />
</TouchableOpacity>
<Text style={styles.topTitle} numberOfLines={1}>{video?.title ?? '视频详情'}</Text>
<TouchableOpacity style={styles.miniBtn} onPress={handleMiniPlayer}>
<Ionicons name="copy-outline" size={22} color="#212121" />
</TouchableOpacity>
</View>
<VideoPlayer uri={streamUrl} />
<VideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
onMiniPlayer={handleMiniPlayer}
/>
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
{videoLoading ? (
@@ -111,6 +133,7 @@ const styles = StyleSheet.create({
topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
backBtn: { padding: 4 },
topTitle: { flex: 1, fontSize: 15, fontWeight: '600', marginLeft: 4, color: '#212121' },
miniBtn: { padding: 4 },
scroll: { flex: 1 },
loader: { marginVertical: 30 },
titleSection: { padding: 14 },

View File

@@ -1,9 +1,5 @@
import { Stack } from 'expo-router';
import { Slot } from 'expo-router';
export default function VideoLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="[bvid]" />
</Stack>
);
return <Slot />;
}