This commit is contained in:
Developer
2026-03-16 21:13:26 +08:00
parent 829d175baa
commit a46e63f0ba
13 changed files with 681 additions and 133 deletions

View File

@@ -35,6 +35,13 @@ export default function RootLayout() {
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="search"
options={{
animation: "slide_from_right",
gestureEnabled: true,
}}
/>
</Stack>
<MiniPlayer />
</View>

View File

@@ -195,51 +195,48 @@ export default function HomeScreen() {
const visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey;
const renderItem = useCallback(
({ item: row }: { item: ListRow }) => {
if (row.type === "big") {
return (
<BigVideoCard
item={row.item}
isVisible={visibleBigKeyRef.current === row.item.bvid}
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
/>
);
}
if (row.type === "live") {
return (
<View style={styles.liveRow}>
<LiveCard
isLivePulse
item={row.left}
fullWidth
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View>
);
}
const right = row.right;
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
if (row.type === "big") {
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={row.left}
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
/>
</View>
{right && (
<View style={styles.rightCol}>
<VideoCard
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
)}
<BigVideoCard
item={row.item}
isVisible={visibleBigKeyRef.current === row.item.bvid}
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
/>
);
}
if (row.type === "live") {
return (
<View style={styles.liveRow}>
<LiveCard
isLivePulse
item={row.left}
fullWidth
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View>
);
},
[],
);
}
const right = row.right;
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={row.left}
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
/>
</View>
{right && (
<View style={styles.rightCol}>
<VideoCard
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
)}
</View>
);
}, []);
const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
@@ -426,11 +423,16 @@ export default function HomeScreen() {
/>
)}
</TouchableOpacity>
{/* <TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity> */}
</View>
<Text style={styles.logo}></Text>
<TouchableOpacity
style={styles.searchBar}
onPress={() => router.push("/search" as any)}
activeOpacity={0.7}
>
<Ionicons name="search" size={14} color="#999" />
<Text style={styles.searchPlaceholder}>UP主...</Text>
</TouchableOpacity>
<Text style={styles.logo}></Text>
</Animated.View>
<View style={styles.tabRow}>
@@ -482,8 +484,8 @@ const styles = StyleSheet.create({
height: HEADER_H,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
gap: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#eee",
},
@@ -492,6 +494,22 @@ const styles = StyleSheet.create({
fontWeight: "800",
color: "#00AEEC",
letterSpacing: -0.5,
width: 72,
},
searchBar: {
flex: 1,
height: 30,
backgroundColor: "#f0f0f0",
borderRadius: 15,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
gap: 5,
},
searchPlaceholder: {
fontSize: 13,
color: "#999",
flex: 1,
},
headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: { paddingLeft: 0 },

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
View,
Text,
@@ -12,7 +12,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLiveDetail } from '../../hooks/useLiveDetail';
import { useLiveDanmaku } from '../../hooks/useLiveDanmaku';
import { LivePlayer } from '../../components/LivePlayer';
import DanmakuList from '../../components/DanmakuList';
import { formatCount } from '../../utils/format';
import { proxyImageUrl } from '../../utils/imageUrl';
@@ -20,10 +22,15 @@ export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const router = useRouter();
const id = parseInt(roomId ?? '0', 10);
const { room, anchor, stream, loading, error } = useLiveDetail(id);
const { room, anchor, stream, loading, error, changeQuality } = useLiveDetail(id);
const [danmakuVisible, setDanmakuVisible] = useState(true);
const isLive = room?.live_status === 1;
const hlsUrl = stream?.hlsUrl ?? '';
const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0;
const danmakus = useLiveDanmaku(isLive ? id : 0);
return (
<SafeAreaView style={styles.safe}>
@@ -38,7 +45,13 @@ export default function LiveDetailScreen() {
</View>
{/* Player */}
<LivePlayer hlsUrl={hlsUrl} isLive={isLive} />
<LivePlayer
hlsUrl={hlsUrl}
isLive={isLive}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
/>
{/* Content */}
{loading ? (
@@ -46,63 +59,74 @@ export default function LiveDetailScreen() {
) : error ? (
<Text style={styles.errorText}>{error}</Text>
) : room ? (
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
{/* Room title */}
<View style={styles.titleSection}>
<Text style={styles.title}>{room.title}</Text>
<View style={styles.metaRow}>
{/* Live status */}
{isLive ? (
<View style={styles.livePill}>
<View style={styles.liveDot} />
<Text style={styles.livePillText}></Text>
<View style={styles.content}>
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
{/* Room title */}
<View style={styles.titleSection}>
<Text style={styles.title}>{room.title}</Text>
<View style={styles.metaRow}>
{/* Live status */}
{isLive ? (
<View style={styles.livePill}>
<View style={styles.liveDot} />
<Text style={styles.livePillText}></Text>
</View>
) : (
<View style={[styles.livePill, styles.offlinePill]}>
<Text style={[styles.livePillText, styles.offlinePillText]}></Text>
</View>
)}
{/* Online count */}
<View style={styles.onlineRow}>
<Ionicons name="eye-outline" size={13} color="#999" />
<Text style={styles.onlineText}>{formatCount(room.online)}</Text>
</View>
) : (
<View style={[styles.livePill, styles.offlinePill]}>
<Text style={[styles.livePillText, styles.offlinePillText]}></Text>
</View>
)}
{/* Online count */}
<View style={styles.onlineRow}>
<Ionicons name="eye-outline" size={13} color="#999" />
<Text style={styles.onlineText}>{formatCount(room.online)}</Text>
</View>
{/* Area tags */}
<View style={styles.areaRow}>
{room.parent_area_name ? (
<Text style={styles.areaTag}>{room.parent_area_name}</Text>
) : null}
{room.area_name ? (
<Text style={styles.areaTag}>{room.area_name}</Text>
) : null}
</View>
</View>
{/* Area tags */}
<View style={styles.areaRow}>
{room.parent_area_name ? (
<Text style={styles.areaTag}>{room.parent_area_name}</Text>
) : null}
{room.area_name ? (
<Text style={styles.areaTag}>{room.area_name}</Text>
) : null}
</View>
</View>
{/* Divider */}
<View style={styles.divider} />
{/* Divider */}
<View style={styles.divider} />
{/* Anchor row */}
{anchor && (
<View style={styles.anchorRow}>
<Image
source={{ uri: proxyImageUrl(anchor.face) }}
style={styles.avatar}
/>
<Text style={styles.anchorName}>{anchor.uname}</Text>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
)}
{/* Anchor row */}
{anchor && (
<View style={styles.anchorRow}>
<Image
source={{ uri: proxyImageUrl(anchor.face) }}
style={styles.avatar}
/>
<Text style={styles.anchorName}>{anchor.uname}</Text>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
)}
{/* Room description */}
{!!room.description && (
<View style={styles.descBox}>
<Text style={styles.descText}>{room.description}</Text>
</View>
)}
</ScrollView>
{/* Room description */}
{!!room.description && (
<View style={styles.descBox}>
<Text style={styles.descText}>{room.description}</Text>
</View>
)}
</ScrollView>
{/* Live danmaku list */}
<DanmakuList
danmakus={danmakus}
currentTime={999999}
visible={danmakuVisible}
onToggle={() => setDanmakuVisible(v => !v)}
style={styles.danmakuList}
/>
</View>
) : null}
</SafeAreaView>
);
@@ -128,6 +152,7 @@ const styles = StyleSheet.create({
},
loader: { marginVertical: 30 },
errorText: { textAlign: 'center', color: '#f00', padding: 20 },
content: { flex: 1 },
scroll: { flex: 1 },
titleSection: { padding: 14 },
title: {
@@ -184,4 +209,5 @@ const styles = StyleSheet.create({
followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' },
descBox: { padding: 14, paddingTop: 4 },
descText: { fontSize: 14, color: '#555', lineHeight: 22 },
danmakuList: { maxHeight: 220 },
});

181
app/search.tsx Normal file
View File

@@ -0,0 +1,181 @@
import React, { useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
FlatList,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { VideoCard } from '../components/VideoCard';
import { useSearch } from '../hooks/useSearch';
import type { VideoItem } from '../services/types';
export default function SearchScreen() {
const router = useRouter();
const { keyword, setKeyword, results, loading, hasMore, search, loadMore } = useSearch();
const inputRef = useRef<TextInput>(null);
const handleSearch = useCallback(() => {
if (keyword.trim()) {
search(keyword, true);
}
}, [keyword, search]);
const renderItem = useCallback(
({ item, index }: { item: VideoItem; index: number }) => {
if (index % 2 !== 0) return null;
const right = results[index + 1];
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={item}
onPress={() => router.push(`/video/${item.bvid}` as any)}
/>
</View>
{right ? (
<View style={styles.rightCol}>
<VideoCard
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
) : (
<View style={styles.rightCol} />
)}
</View>
);
},
[results, router],
);
const keyExtractor = useCallback(
(_: VideoItem, index: number) => String(index),
[],
);
const ListEmptyComponent = () => {
if (loading) return null;
return (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={styles.emptyText}>
{results.length === 0 && keyword.trim()
? '没有找到相关视频'
: '输入关键词搜索'}
</Text>
</View>
);
};
return (
<SafeAreaView style={styles.safe} edges={['top', 'left', 'right']}>
{/* Search header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#212121" />
</TouchableOpacity>
<View style={styles.inputWrap}>
<TextInput
ref={inputRef}
style={styles.input}
placeholder="搜索视频、UP主..."
placeholderTextColor="#999"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={handleSearch}
returnKeyType="search"
autoFocus
autoCapitalize="none"
autoCorrect={false}
/>
{keyword.length > 0 && (
<TouchableOpacity onPress={() => setKeyword('')} style={styles.clearBtn}>
<Ionicons name="close-circle" size={16} color="#bbb" />
</TouchableOpacity>
)}
</View>
<TouchableOpacity style={styles.searchBtn} onPress={handleSearch}>
<Text style={styles.searchBtnText}></Text>
</TouchableOpacity>
</View>
{/* Results */}
<FlatList
data={results}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={<ListEmptyComponent />}
ListFooterComponent={
loading && results.length > 0 ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
</View>
) : null
}
keyboardShouldPersistTaps="handled"
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f4f4f4' },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
gap: 6,
},
backBtn: { padding: 4 },
inputWrap: {
flex: 1,
height: 34,
backgroundColor: '#f0f0f0',
borderRadius: 17,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
},
input: {
flex: 1,
fontSize: 14,
color: '#212121',
padding: 0,
},
clearBtn: { paddingLeft: 4 },
searchBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
},
searchBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
listContent: { paddingTop: 6, paddingBottom: 20 },
row: {
flexDirection: 'row',
paddingHorizontal: 1,
justifyContent: 'flex-start',
},
leftCol: { flex: 1, marginLeft: 4, marginRight: 2 },
rightCol: { flex: 1, marginLeft: 2, marginRight: 4 },
emptyBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 80,
gap: 12,
},
emptyText: { fontSize: 14, color: '#bbb' },
footer: { height: 48, alignItems: 'center', justifyContent: 'center' },
});

View File

@@ -37,7 +37,6 @@ export default function VideoDetailScreen() {
changeQuality,
} = useVideoDetail(bvid as string);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const flatListHeightRef = useRef(0);
const {
comments,
loading: cmtLoading,
@@ -174,12 +173,6 @@ export default function VideoDetailScreen() {
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
onEndReachedThreshold={0.3}
onLayout={({ nativeEvent }) => { flatListHeightRef.current = nativeEvent.layout.height; }}
onContentSizeChange={(_, contentHeight) => {
if (contentHeight < flatListHeightRef.current && cmtHasMore && !cmtLoading) {
loadComments();
}
}}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.sortRow}>
@@ -240,11 +233,14 @@ function SeasonSection({
useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return;
// 等布局完成再滚动
const t = setTimeout(() => {
listRef.current?.scrollToIndex({
index: currentIndex,
viewPosition: 0.5, // 居中
animated: false,
});
}, 200);
return () => clearTimeout(t);
}, [currentIndex, episodes.length]);
return (
@@ -414,9 +410,9 @@ const styles = StyleSheet.create({
},
sortLabel: { fontSize: 13, color: "#999", marginRight: 4 },
sortBtn: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 3,
borderRadius: 20,
borderWidth: 1,
borderColor: "#e0e0e0",
},