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", gestureDirection: "horizontal",
}} }}
/> />
<Stack.Screen
name="search"
options={{
animation: "slide_from_right",
gestureEnabled: true,
}}
/>
</Stack> </Stack>
<MiniPlayer /> <MiniPlayer />
</View> </View>

View File

@@ -195,51 +195,48 @@ export default function HomeScreen() {
const visibleBigKeyRef = useRef(visibleBigKey); const visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey; visibleBigKeyRef.current = visibleBigKey;
const renderItem = useCallback( const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
({ item: row }: { item: ListRow }) => { if (row.type === "big") {
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;
return ( return (
<View style={styles.row}> <BigVideoCard
<View style={styles.leftCol}> item={row.item}
<VideoCard isVisible={visibleBigKeyRef.current === row.item.bvid}
item={row.left} onPress={() => router.push(`/video/${row.item.bvid}` as any)}
onPress={() => router.push(`/video/${row.left.bvid}` as any)} />
/> );
</View> }
{right && ( if (row.type === "live") {
<View style={styles.rightCol}> return (
<VideoCard <View style={styles.liveRow}>
item={right} <LiveCard
onPress={() => router.push(`/video/${right.bvid}` as any)} isLivePulse
/> item={row.left}
</View> fullWidth
)} onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View> </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( const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => ( ({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
@@ -426,11 +423,16 @@ export default function HomeScreen() {
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
{/* <TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity> */}
</View> </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> </Animated.View>
<View style={styles.tabRow}> <View style={styles.tabRow}>
@@ -482,8 +484,8 @@ const styles = StyleSheet.create({
height: HEADER_H, height: HEADER_H,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16, paddingHorizontal: 16,
gap: 10,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#eee", borderBottomColor: "#eee",
}, },
@@ -492,6 +494,22 @@ const styles = StyleSheet.create({
fontWeight: "800", fontWeight: "800",
color: "#00AEEC", color: "#00AEEC",
letterSpacing: -0.5, 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" }, headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: { paddingLeft: 0 }, headerBtn: { paddingLeft: 0 },

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -12,7 +12,9 @@ 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';
import { useLiveDetail } from '../../hooks/useLiveDetail'; import { useLiveDetail } from '../../hooks/useLiveDetail';
import { useLiveDanmaku } from '../../hooks/useLiveDanmaku';
import { LivePlayer } from '../../components/LivePlayer'; import { LivePlayer } from '../../components/LivePlayer';
import DanmakuList from '../../components/DanmakuList';
import { formatCount } from '../../utils/format'; import { formatCount } from '../../utils/format';
import { proxyImageUrl } from '../../utils/imageUrl'; import { proxyImageUrl } from '../../utils/imageUrl';
@@ -20,10 +22,15 @@ export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>(); const { roomId } = useLocalSearchParams<{ roomId: string }>();
const router = useRouter(); const router = useRouter();
const id = parseInt(roomId ?? '0', 10); 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 isLive = room?.live_status === 1;
const hlsUrl = stream?.hlsUrl ?? ''; const hlsUrl = stream?.hlsUrl ?? '';
const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0;
const danmakus = useLiveDanmaku(isLive ? id : 0);
return ( return (
<SafeAreaView style={styles.safe}> <SafeAreaView style={styles.safe}>
@@ -38,7 +45,13 @@ export default function LiveDetailScreen() {
</View> </View>
{/* Player */} {/* Player */}
<LivePlayer hlsUrl={hlsUrl} isLive={isLive} /> <LivePlayer
hlsUrl={hlsUrl}
isLive={isLive}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
/>
{/* Content */} {/* Content */}
{loading ? ( {loading ? (
@@ -46,63 +59,74 @@ export default function LiveDetailScreen() {
) : error ? ( ) : error ? (
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
) : room ? ( ) : room ? (
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}> <View style={styles.content}>
{/* Room title */} <ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<View style={styles.titleSection}> {/* Room title */}
<Text style={styles.title}>{room.title}</Text> <View style={styles.titleSection}>
<View style={styles.metaRow}> <Text style={styles.title}>{room.title}</Text>
{/* Live status */} <View style={styles.metaRow}>
{isLive ? ( {/* Live status */}
<View style={styles.livePill}> {isLive ? (
<View style={styles.liveDot} /> <View style={styles.livePill}>
<Text style={styles.livePillText}></Text> <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>
) : ( </View>
<View style={[styles.livePill, styles.offlinePill]}> {/* Area tags */}
<Text style={[styles.livePillText, styles.offlinePillText]}></Text> <View style={styles.areaRow}>
</View> {room.parent_area_name ? (
)} <Text style={styles.areaTag}>{room.parent_area_name}</Text>
{/* Online count */} ) : null}
<View style={styles.onlineRow}> {room.area_name ? (
<Ionicons name="eye-outline" size={13} color="#999" /> <Text style={styles.areaTag}>{room.area_name}</Text>
<Text style={styles.onlineText}>{formatCount(room.online)}</Text> ) : null}
</View> </View>
</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 */} {/* Divider */}
<View style={styles.divider} /> <View style={styles.divider} />
{/* Anchor row */} {/* Anchor row */}
{anchor && ( {anchor && (
<View style={styles.anchorRow}> <View style={styles.anchorRow}>
<Image <Image
source={{ uri: proxyImageUrl(anchor.face) }} source={{ uri: proxyImageUrl(anchor.face) }}
style={styles.avatar} style={styles.avatar}
/> />
<Text style={styles.anchorName}>{anchor.uname}</Text> <Text style={styles.anchorName}>{anchor.uname}</Text>
<TouchableOpacity style={styles.followBtn}> <TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text> <Text style={styles.followTxt}>+ </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
{/* Room description */} {/* Room description */}
{!!room.description && ( {!!room.description && (
<View style={styles.descBox}> <View style={styles.descBox}>
<Text style={styles.descText}>{room.description}</Text> <Text style={styles.descText}>{room.description}</Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>
{/* Live danmaku list */}
<DanmakuList
danmakus={danmakus}
currentTime={999999}
visible={danmakuVisible}
onToggle={() => setDanmakuVisible(v => !v)}
style={styles.danmakuList}
/>
</View>
) : null} ) : null}
</SafeAreaView> </SafeAreaView>
); );
@@ -128,6 +152,7 @@ const styles = StyleSheet.create({
}, },
loader: { marginVertical: 30 }, loader: { marginVertical: 30 },
errorText: { textAlign: 'center', color: '#f00', padding: 20 }, errorText: { textAlign: 'center', color: '#f00', padding: 20 },
content: { flex: 1 },
scroll: { flex: 1 }, scroll: { flex: 1 },
titleSection: { padding: 14 }, titleSection: { padding: 14 },
title: { title: {
@@ -184,4 +209,5 @@ const styles = StyleSheet.create({
followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' }, followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' },
descBox: { padding: 14, paddingTop: 4 }, descBox: { padding: 14, paddingTop: 4 },
descText: { fontSize: 14, color: '#555', lineHeight: 22 }, 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, changeQuality,
} = useVideoDetail(bvid as string); } = useVideoDetail(bvid as string);
const [commentSort, setCommentSort] = useState<0 | 2>(2); const [commentSort, setCommentSort] = useState<0 | 2>(2);
const flatListHeightRef = useRef(0);
const { const {
comments, comments,
loading: cmtLoading, loading: cmtLoading,
@@ -174,12 +173,6 @@ export default function VideoDetailScreen() {
renderItem={({ item }) => <CommentItem item={item} />} renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }} onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
onEndReachedThreshold={0.3} onEndReachedThreshold={0.3}
onLayout={({ nativeEvent }) => { flatListHeightRef.current = nativeEvent.layout.height; }}
onContentSizeChange={(_, contentHeight) => {
if (contentHeight < flatListHeightRef.current && cmtHasMore && !cmtLoading) {
loadComments();
}
}}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
ListHeaderComponent={ ListHeaderComponent={
<View style={styles.sortRow}> <View style={styles.sortRow}>
@@ -240,11 +233,14 @@ function SeasonSection({
useEffect(() => { useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return; if (currentIndex <= 0 || episodes.length === 0) return;
// 等布局完成再滚动 // 等布局完成再滚动
const t = setTimeout(() => {
listRef.current?.scrollToIndex({ listRef.current?.scrollToIndex({
index: currentIndex, index: currentIndex,
viewPosition: 0.5, // 居中 viewPosition: 0.5, // 居中
animated: false, animated: false,
}); });
}, 200);
return () => clearTimeout(t);
}, [currentIndex, episodes.length]); }, [currentIndex, episodes.length]);
return ( return (
@@ -414,9 +410,9 @@ const styles = StyleSheet.create({
}, },
sortLabel: { fontSize: 13, color: "#999", marginRight: 4 }, sortLabel: { fontSize: 13, color: "#999", marginRight: 4 },
sortBtn: { sortBtn: {
paddingHorizontal: 12, paddingHorizontal: 14,
paddingVertical: 4, paddingVertical: 3,
borderRadius: 12, borderRadius: 20,
borderWidth: 1, borderWidth: 1,
borderColor: "#e0e0e0", borderColor: "#e0e0e0",
}, },

View File

@@ -13,7 +13,10 @@ import { Ionicons } from '@expo/vector-icons';
interface Props { interface Props {
hlsUrl: string; hlsUrl: string;
isLive: boolean; // false → show offline placeholder isLive: boolean;
qualities?: { qn: number; desc: string }[];
currentQn?: number;
onQualityChange?: (qn: number) => void;
} }
const HIDE_DELAY = 3000; const HIDE_DELAY = 3000;
@@ -24,7 +27,7 @@ const HEADERS = {
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}; };
export function LivePlayer({ hlsUrl, isLive }: Props) { export function LivePlayer({ hlsUrl, isLive, qualities = [], currentQn = 0, onQualityChange }: Props) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions(); const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625; const VIDEO_H = SCREEN_W * 0.5625;
@@ -45,7 +48,17 @@ export function LivePlayer({ hlsUrl, isLive }: Props) {
); );
} }
return <NativeLivePlayer hlsUrl={hlsUrl} screenW={SCREEN_W} screenH={SCREEN_H} videoH={VIDEO_H} />; return (
<NativeLivePlayer
hlsUrl={hlsUrl}
screenW={SCREEN_W}
screenH={SCREEN_H}
videoH={VIDEO_H}
qualities={qualities}
currentQn={currentQn}
onQualityChange={onQualityChange}
/>
);
} }
function NativeLivePlayer({ function NativeLivePlayer({
@@ -53,19 +66,25 @@ function NativeLivePlayer({
screenW, screenW,
screenH, screenH,
videoH, videoH,
qualities,
currentQn,
onQualityChange,
}: { }: {
hlsUrl: string; hlsUrl: string;
screenW: number; screenW: number;
screenH: number; screenH: number;
videoH: number; videoH: number;
qualities: { qn: number; desc: string }[];
currentQn: number;
onQualityChange?: (qn: number) => void;
}) { }) {
// Lazy import to avoid web bundle issues
const Video = require('react-native-video').default; const Video = require('react-native-video').default;
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [buffering, setBuffering] = useState(true); const [buffering, setBuffering] = useState(true);
const [showQualityPanel, setShowQualityPanel] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetHideTimer = useCallback(() => { const resetHideTimer = useCallback(() => {
@@ -78,6 +97,38 @@ function NativeLivePlayer({
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, []); }, []);
// Lock/unlock orientation on fullscreen toggle
useEffect(() => {
(async () => {
try {
const ScreenOrientation = require('expo-screen-orientation');
if (isFullscreen) {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
);
} else {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
} catch { /* graceful degradation in Expo Go */ }
})();
}, [isFullscreen]);
// Restore portrait on unmount
useEffect(() => {
return () => {
(async () => {
try {
const ScreenOrientation = require('expo-screen-orientation');
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
} catch { /* ignore */ }
})();
};
}, []);
const handleTap = useCallback(() => { const handleTap = useCallback(() => {
setShowControls(prev => { setShowControls(prev => {
if (!prev) { resetHideTimer(); return true; } if (!prev) { resetHideTimer(); return true; }
@@ -90,6 +141,8 @@ function NativeLivePlayer({
? { width: screenH, height: screenW } ? { width: screenH, height: screenW }
: { width: screenW, height: videoH }; : { width: screenW, height: videoH };
const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? '';
const videoContent = ( const videoContent = (
<View style={[styles.container, containerStyle]}> <View style={[styles.container, containerStyle]}>
<Video <Video
@@ -118,7 +171,7 @@ function NativeLivePlayer({
{/* LIVE badge top-left */} {/* LIVE badge top-left */}
<View style={styles.liveBadge} pointerEvents="none"> <View style={styles.liveBadge} pointerEvents="none">
<View style={styles.liveDot} /> <View style={styles.liveDot} />
<Text style={styles.liveText}>LIVE</Text> <Text style={styles.liveText}>live</Text>
</View> </View>
{/* Center play/pause */} {/* Center play/pause */}
@@ -140,15 +193,53 @@ function NativeLivePlayer({
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" /> <Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
{qualities.length > 0 && (
<TouchableOpacity
style={styles.qualityBtn}
onPress={() => { setShowQualityPanel(true); resetHideTimer(); }}
>
<Text style={styles.qualityText}>{currentQnDesc || '清晰度'}</Text>
</TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
style={styles.ctrlBtn} style={styles.ctrlBtn}
onPress={() => setIsFullscreen(fs => !fs)} onPress={() => { setIsFullscreen(fs => !fs); resetHideTimer(); }}
> >
<Ionicons name={isFullscreen ? 'contract' : 'expand'} size={16} color="#fff" /> <Ionicons name={isFullscreen ? 'contract' : 'expand'} size={16} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</> </>
)} )}
{/* Quality selector panel */}
{showQualityPanel && (
<TouchableWithoutFeedback onPress={() => setShowQualityPanel(false)}>
<View style={styles.qualityOverlay}>
<TouchableWithoutFeedback>
<View style={styles.qualityPanel}>
<Text style={styles.qualityPanelTitle}></Text>
{qualities.map(q => (
<TouchableOpacity
key={q.qn}
style={[styles.qualityItem, currentQn === q.qn && styles.qualityItemActive]}
onPress={() => {
onQualityChange?.(q.qn);
setShowQualityPanel(false);
}}
>
<Text style={[styles.qualityItemText, currentQn === q.qn && styles.qualityItemTextActive]}>
{q.desc}
</Text>
{currentQn === q.qn && (
<Ionicons name="checkmark" size={14} color="#00AEEC" />
)}
</TouchableOpacity>
))}
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
)}
</View> </View>
); );
@@ -223,6 +314,44 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0,0,0,0)', backgroundColor: 'rgba(0,0,0,0)',
}, },
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 }, ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
qualityBtn: {
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: 'rgba(0,0,0,0.4)',
borderRadius: 4,
marginRight: 4,
},
qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' },
qualityOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
qualityPanel: {
backgroundColor: 'rgba(20,20,20,0.95)',
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
paddingBottom: 16,
},
qualityPanelTitle: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
qualityItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 12,
},
qualityItemActive: { backgroundColor: 'rgba(0,174,236,0.15)' },
qualityItemText: { color: '#ccc', fontSize: 14 },
qualityItemTextActive: { color: '#00AEEC', fontWeight: '600' },
fsModal: { fsModal: {
flex: 1, flex: 1,
backgroundColor: '#000', backgroundColor: '#000',

View File

@@ -40,7 +40,7 @@ function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v)); return Math.max(lo, Math.min(hi, v));
} }
// index[i] = timestamp (seconds) for frame i. Returns frame number (position i), not index value. // Binary search in shots index to find frame number for given seek time
function findFrameByTime(index: number[], seekTime: number): number { function findFrameByTime(index: number[], seekTime: number): number {
let lo = 0, hi = index.length - 1; let lo = 0, hi = index.length - 1;
while (lo < hi) { while (lo < hi) {
@@ -299,6 +299,7 @@ export function NativeVideoPlayer({
const sheetUrl = image[sheetIdx].startsWith("//") const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}` ? `https:${image[sheetIdx]}`
: image[sheetIdx]; : image[sheetIdx];
console.log(sheetUrl,'sheetUrlsheetUrl')
return ( return (
<View <View
style={[styles.thumbPreview, { left: absLeft, width: DW }]} style={[styles.thumbPreview, { left: absLeft, width: DW }]}

96
hooks/useLiveDanmaku.ts Normal file
View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useRef } from 'react';
import type { DanmakuItem } from '../services/types';
function buildPacket(body: string, op: number): ArrayBuffer {
const bodyBytes = new TextEncoder().encode(body);
const total = 16 + bodyBytes.length;
const buf = new ArrayBuffer(total);
const view = new DataView(buf);
view.setUint32(0, total, false); // total_len
view.setUint16(4, 16, false); // header_len
view.setUint16(6, 0, false); // ver=0 (no compression)
view.setUint32(8, op, false); // op
view.setUint32(12, 1, false); // seq
new Uint8Array(buf).set(bodyBytes, 16);
return buf;
}
function parsePackets(buf: ArrayBuffer): { op: number; body: string }[] {
const packets: { op: number; body: string }[] = [];
let offset = 0;
const view = new DataView(buf);
while (offset + 16 <= buf.byteLength) {
const totalLen = view.getUint32(offset, false);
const headerLen = view.getUint16(offset + 4, false);
const op = view.getUint32(offset + 8, false);
if (totalLen < headerLen || offset + totalLen > buf.byteLength) break;
const bodyLen = totalLen - headerLen;
const bodyBytes = new Uint8Array(buf, offset + headerLen, bodyLen);
const body = new TextDecoder('utf-8').decode(bodyBytes);
packets.push({ op, body });
offset += totalLen;
}
return packets;
}
export function useLiveDanmaku(roomId: number): DanmakuItem[] {
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!roomId) return;
setDanmakus([]);
const ws = new WebSocket('wss://broadcastlv.chat.bilibili.com/sub');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
const authBody = JSON.stringify({
roomid: roomId,
platform: 'web',
type: 2,
uid: 0,
protover: 0,
});
ws.send(buildPacket(authBody, 7));
heartbeatRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(buildPacket('', 2));
}
}, 30000);
};
ws.onmessage = (e: MessageEvent) => {
try {
const packets = parsePackets(e.data as ArrayBuffer);
for (const pkt of packets) {
if (pkt.op === 5 && pkt.body) {
try {
const msg = JSON.parse(pkt.body);
if (msg.cmd === 'DANMU_MSG') {
const info = msg.info;
const text = info[1] as string;
const color = (info[3]?.[3] as number) ?? 0xffffff;
const item: DanmakuItem = { time: 0, mode: 1, fontSize: 25, color, text };
setDanmakus(prev => [...prev, item]);
}
} catch { /* ignore single message parse error */ }
}
}
} catch { /* ignore */ }
};
ws.onerror = () => {};
ws.onclose = () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
};
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
ws.close();
};
}, [roomId]);
return danmakus;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili'; import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili';
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types'; import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
@@ -33,7 +33,7 @@ export function useLiveDetail(roomId: number) {
]); ]);
if (cancelled) return; if (cancelled) return;
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0 }; let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
if (room.live_status === 1) { if (room.live_status === 1) {
stream = await getLiveStreamUrl(roomId); stream = await getLiveStreamUrl(roomId);
} }
@@ -50,5 +50,12 @@ export function useLiveDetail(roomId: number) {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [roomId]); }, [roomId]);
return state; const changeQuality = useCallback(async (qn: number) => {
try {
const stream = await getLiveStreamUrl(roomId, qn);
setState(prev => ({ ...prev, stream }));
} catch { /* ignore */ }
}, [roomId]);
return { ...state, changeQuality };
} }

42
hooks/useSearch.ts Normal file
View File

@@ -0,0 +1,42 @@
import { useState, useCallback, useRef } from 'react';
import { searchVideos } from '../services/bilibili';
import type { VideoItem } from '../services/types';
export function useSearch() {
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<VideoItem[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadingRef = useRef(false);
const search = useCallback(async (kw: string, reset = false) => {
if (!kw.trim() || loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
const currentPage = reset ? 1 : page;
try {
const items = await searchVideos(kw, currentPage);
if (reset) {
setResults(items);
setPage(2);
} else {
setResults(prev => [...prev, ...items]);
setPage(p => p + 1);
}
setHasMore(items.length >= 20);
} catch {
setHasMore(false);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [page]);
const loadMore = useCallback(() => {
if (!keyword.trim() || loadingRef.current || !hasMore) return;
search(keyword, false);
}, [keyword, hasMore, search]);
return { keyword, setKeyword, results, loading, hasMore, search, loadMore };
}

View File

@@ -13,14 +13,17 @@ export function useVideoList() {
const freshIdxRef = useRef(0); const freshIdxRef = useRef(0);
const load = useCallback(async (reset = false) => { const load = useCallback(async (reset = false) => {
if (loadingRef.current) return; if (loadingRef.current) {
if (reset) setRefreshing(false);
return;
}
loadingRef.current = true; loadingRef.current = true;
const idx = reset ? 0 : freshIdxRef.current; const idx = freshIdxRef.current;
setLoading(true); setLoading(true);
try { try {
const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [ const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [
getRecommendFeed(idx), getRecommendFeed(idx),
reset || idx === 0 (reset || idx === 0)
? getLiveList(1, 0).catch(() => [] as LiveRoom[]) ? getLiveList(1, 0).catch(() => [] as LiveRoom[])
: Promise.resolve([] as LiveRoom[]), : Promise.resolve([] as LiveRoom[]),
]; ];
@@ -42,11 +45,11 @@ export function useVideoList() {
}, []); // stable — no stale closure risk }, []); // stable — no stale closure risk
const refresh = useCallback(() => { const refresh = useCallback(() => {
console.log('Refreshing video list');
setRefreshing(true); setRefreshing(true);
load(true); load(true);
}, [load]); }, [load]);
const videos = useMemo(() => pages.flat(), [pages]); const videos = useMemo(() => pages.flat(), [pages]);
return { videos, pages, liveRooms, loading, refreshing, load, refresh }; return { videos, pages, liveRooms, loading, refreshing, load, refresh };
} }

View File

@@ -94,7 +94,6 @@ export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
); );
const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed }); const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed });
const items: any[] = res.data.data?.item ?? []; const items: any[] = res.data.data?.item ?? [];
console.log(items,'items')
return items return items
.filter(item => item.goto === 'av' && item.bvid && item.title) .filter(item => item.goto === 'av' && item.bvid && item.title)
.map(item => ({ .map(item => ({
@@ -259,15 +258,19 @@ export async function getLiveAnchorInfo(roomId: number): Promise<LiveAnchorInfo>
return { uid: info.uid, uname: info.uname, face: info.face } as LiveAnchorInfo; return { uid: info.uid, uname: info.uname, face: info.face } as LiveAnchorInfo;
} }
export async function getLiveStreamUrl(roomId: number): Promise<LiveStreamInfo> { export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise<LiveStreamInfo> {
try { try {
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v2/index/getRoomPlayInfo`, { const res = await api.get(`${LIVE_BASE}/xlive/web-room/v2/index/getRoomPlayInfo`, {
params: { room_id: roomId, protocol: '0,1', format: '0,1,2', codec: '0', qn: 10000 }, params: { room_id: roomId, protocol: '0,1', format: '0,1,2', codec: '0', qn },
}); });
const streams: any[] = res.data?.data?.playurl_info?.playurl?.stream ?? []; const playurl = res.data?.data?.playurl_info?.playurl;
const streams: any[] = playurl?.stream ?? [];
const gQnDesc: any[] = playurl?.g_qn_desc ?? [];
const qualities = gQnDesc.map((q: any) => ({ qn: q.qn as number, desc: q.desc as string }));
let hlsUrl = ''; let hlsUrl = '';
let flvUrl = ''; let flvUrl = '';
let qn = 0; let currentQn = 0;
const hlsStream = streams.find(s => s.protocol_name === 'http_hls'); const hlsStream = streams.find(s => s.protocol_name === 'http_hls');
if (hlsStream) { if (hlsStream) {
@@ -276,7 +279,7 @@ export async function getLiveStreamUrl(roomId: number): Promise<LiveStreamInfo>
const urlInfo = codec?.url_info?.[0]; const urlInfo = codec?.url_info?.[0];
if (urlInfo) { if (urlInfo) {
hlsUrl = urlInfo.host + codec.base_url; hlsUrl = urlInfo.host + codec.base_url;
qn = codec.current_qn ?? 0; currentQn = codec.current_qn ?? 0;
} }
} }
@@ -290,12 +293,50 @@ export async function getLiveStreamUrl(roomId: number): Promise<LiveStreamInfo>
} }
} }
return { hlsUrl, flvUrl, qn }; return { hlsUrl, flvUrl, qn: currentQn, qualities };
} catch { } catch {
return { hlsUrl: '', flvUrl: '', qn: 0 }; return { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
} }
} }
function parseDuration(s: string): number {
const parts = s.split(':').map(Number);
return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
}
export async function searchVideos(keyword: string, page = 1): Promise<VideoItem[]> {
const { imgKey, subKey } = await getWbiKeys();
const signed = signWbi(
{ keyword, search_type: 'video', page, page_size: 20 },
imgKey,
subKey,
);
const res = await api.get('/x/web-interface/wbi/search/type', { params: signed });
const results: any[] = res.data.data?.result ?? [];
return results
.filter((item: any) => item.bvid && item.title)
.map((item: any) => ({
bvid: item.bvid,
aid: item.aid ?? 0,
title: item.title.replace(/<[^>]+>/g, ''),
pic: item.pic ? (item.pic.startsWith('//') ? `https:${item.pic}` : item.pic) : '',
owner: { mid: item.mid ?? 0, name: item.author ?? '', face: '' },
stat: {
view: item.play ?? 0,
danmaku: item.video_review ?? 0,
reply: item.review ?? 0,
like: 0,
coin: 0,
favorite: 0,
},
duration: item.duration ? parseDuration(item.duration) : 0,
desc: item.description ?? '',
cid: 0,
pages: [],
ugc_season: undefined,
} as VideoItem));
}
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> { export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
try { try {
if (isWeb) { if (isWeb) {

View File

@@ -153,4 +153,5 @@ export interface LiveStreamInfo {
hlsUrl: string; hlsUrl: string;
flvUrl: string; flvUrl: string;
qn: number; qn: number;
qualities: { qn: number; desc: string }[];
} }