mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
1
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
112
app/index.tsx
112
app/index.tsx
@@ -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 },
|
||||||
|
|||||||
@@ -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
181
app/search.tsx
Normal 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' },
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
96
hooks/useLiveDanmaku.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
42
hooks/useSearch.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -153,4 +153,5 @@ export interface LiveStreamInfo {
|
|||||||
hlsUrl: string;
|
hlsUrl: string;
|
||||||
flvUrl: string;
|
flvUrl: string;
|
||||||
qn: number;
|
qn: number;
|
||||||
|
qualities: { qn: number; desc: string }[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user