diff --git a/app/_layout.tsx b/app/_layout.tsx
index 6e525cc..6a1b379 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -35,6 +35,13 @@ export default function RootLayout() {
gestureDirection: "horizontal",
}}
/>
+
diff --git a/app/index.tsx b/app/index.tsx
index c6da0b3..3d7db20 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -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 (
- router.push(`/video/${row.item.bvid}` as any)}
- />
- );
- }
- if (row.type === "live") {
- return (
-
- router.push(`/live/${row.left.roomid}` as any)}
- />
-
- );
- }
- const right = row.right;
+ const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
+ if (row.type === "big") {
return (
-
-
- router.push(`/video/${row.left.bvid}` as any)}
- />
-
- {right && (
-
- router.push(`/video/${right.bvid}` as any)}
- />
-
- )}
+ router.push(`/video/${row.item.bvid}` as any)}
+ />
+ );
+ }
+ if (row.type === "live") {
+ return (
+
+ router.push(`/live/${row.left.roomid}` as any)}
+ />
);
- },
- [],
- );
+ }
+ const right = row.right;
+ return (
+
+
+ router.push(`/video/${row.left.bvid}` as any)}
+ />
+
+ {right && (
+
+ router.push(`/video/${right.bvid}` as any)}
+ />
+
+ )}
+
+ );
+ }, []);
const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
@@ -426,11 +423,16 @@ export default function HomeScreen() {
/>
)}
- {/*
-
- */}
- 哔哩哔哩
+ router.push("/search" as any)}
+ activeOpacity={0.7}
+ >
+
+ 搜索视频、UP主...
+
+ 哔
@@ -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 },
diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx
index 6251e33..d9bc498 100644
--- a/app/live/[roomId].tsx
+++ b/app/live/[roomId].tsx
@@ -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 (
@@ -38,7 +45,13 @@ export default function LiveDetailScreen() {
{/* Player */}
-
+
{/* Content */}
{loading ? (
@@ -46,63 +59,74 @@ export default function LiveDetailScreen() {
) : error ? (
{error}
) : room ? (
-
- {/* Room title */}
-
- {room.title}
-
- {/* Live status */}
- {isLive ? (
-
-
- 直播中
+
+
+ {/* Room title */}
+
+ {room.title}
+
+ {/* Live status */}
+ {isLive ? (
+
+
+ 直播中
+
+ ) : (
+
+ 未开播
+
+ )}
+ {/* Online count */}
+
+
+ {formatCount(room.online)}
- ) : (
-
- 未开播
-
- )}
- {/* Online count */}
-
-
- {formatCount(room.online)}
+
+ {/* Area tags */}
+
+ {room.parent_area_name ? (
+ {room.parent_area_name}
+ ) : null}
+ {room.area_name ? (
+ {room.area_name}
+ ) : null}
- {/* Area tags */}
-
- {room.parent_area_name ? (
- {room.parent_area_name}
- ) : null}
- {room.area_name ? (
- {room.area_name}
- ) : null}
-
-
- {/* Divider */}
-
+ {/* Divider */}
+
- {/* Anchor row */}
- {anchor && (
-
-
- {anchor.uname}
-
- + 关注
-
-
- )}
+ {/* Anchor row */}
+ {anchor && (
+
+
+ {anchor.uname}
+
+ + 关注
+
+
+ )}
- {/* Room description */}
- {!!room.description && (
-
- {room.description}
-
- )}
-
+ {/* Room description */}
+ {!!room.description && (
+
+ {room.description}
+
+ )}
+
+
+ {/* Live danmaku list */}
+ setDanmakuVisible(v => !v)}
+ style={styles.danmakuList}
+ />
+
) : null}
);
@@ -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 },
});
diff --git a/app/search.tsx b/app/search.tsx
new file mode 100644
index 0000000..12324ee
--- /dev/null
+++ b/app/search.tsx
@@ -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(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 (
+
+
+ router.push(`/video/${item.bvid}` as any)}
+ />
+
+ {right ? (
+
+ router.push(`/video/${right.bvid}` as any)}
+ />
+
+ ) : (
+
+ )}
+
+ );
+ },
+ [results, router],
+ );
+
+ const keyExtractor = useCallback(
+ (_: VideoItem, index: number) => String(index),
+ [],
+ );
+
+ const ListEmptyComponent = () => {
+ if (loading) return null;
+ return (
+
+
+
+ {results.length === 0 && keyword.trim()
+ ? '没有找到相关视频'
+ : '输入关键词搜索'}
+
+
+ );
+ };
+
+ return (
+
+ {/* Search header */}
+
+ router.back()} style={styles.backBtn}>
+
+
+
+
+ {keyword.length > 0 && (
+ setKeyword('')} style={styles.clearBtn}>
+
+
+ )}
+
+
+ 搜索
+
+
+
+ {/* Results */}
+ }
+ ListFooterComponent={
+ loading && results.length > 0 ? (
+
+
+
+ ) : null
+ }
+ keyboardShouldPersistTaps="handled"
+ />
+
+ );
+}
+
+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' },
+});
diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx
index bd0d4f5..bf62979 100644
--- a/app/video/[bvid].tsx
+++ b/app/video/[bvid].tsx
@@ -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 }) => }
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={
@@ -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",
},
diff --git a/components/LivePlayer.tsx b/components/LivePlayer.tsx
index 19f52f8..8860d4b 100644
--- a/components/LivePlayer.tsx
+++ b/components/LivePlayer.tsx
@@ -13,7 +13,10 @@ import { Ionicons } from '@expo/vector-icons';
interface Props {
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;
@@ -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',
};
-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 VIDEO_H = SCREEN_W * 0.5625;
@@ -45,7 +48,17 @@ export function LivePlayer({ hlsUrl, isLive }: Props) {
);
}
- return ;
+ return (
+
+ );
}
function NativeLivePlayer({
@@ -53,19 +66,25 @@ function NativeLivePlayer({
screenW,
screenH,
videoH,
+ qualities,
+ currentQn,
+ onQualityChange,
}: {
hlsUrl: string;
screenW: number;
screenH: 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 [showControls, setShowControls] = useState(true);
const [paused, setPaused] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [buffering, setBuffering] = useState(true);
+ const [showQualityPanel, setShowQualityPanel] = useState(false);
const hideTimer = useRef | null>(null);
const resetHideTimer = useCallback(() => {
@@ -78,6 +97,38 @@ function NativeLivePlayer({
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(() => {
setShowControls(prev => {
if (!prev) { resetHideTimer(); return true; }
@@ -90,6 +141,8 @@ function NativeLivePlayer({
? { width: screenH, height: screenW }
: { width: screenW, height: videoH };
+ const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? '';
+
const videoContent = (
{/* Center play/pause */}
@@ -140,15 +193,53 @@ function NativeLivePlayer({
+ {qualities.length > 0 && (
+ { setShowQualityPanel(true); resetHideTimer(); }}
+ >
+ {currentQnDesc || '清晰度'}
+
+ )}
setIsFullscreen(fs => !fs)}
+ onPress={() => { setIsFullscreen(fs => !fs); resetHideTimer(); }}
>
>
)}
+
+ {/* Quality selector panel */}
+ {showQualityPanel && (
+ setShowQualityPanel(false)}>
+
+
+
+ 清晰度
+ {qualities.map(q => (
+ {
+ onQualityChange?.(q.qn);
+ setShowQualityPanel(false);
+ }}
+ >
+
+ {q.desc}
+
+ {currentQn === q.qn && (
+
+ )}
+
+ ))}
+
+
+
+
+ )}
);
@@ -223,6 +314,44 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0,0,0,0)',
},
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: {
flex: 1,
backgroundColor: '#000',
diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx
index d73f57e..764aaaf 100644
--- a/components/NativeVideoPlayer.tsx
+++ b/components/NativeVideoPlayer.tsx
@@ -40,7 +40,7 @@ function clamp(v: number, lo: number, hi: number) {
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 {
let lo = 0, hi = index.length - 1;
while (lo < hi) {
@@ -299,6 +299,7 @@ export function NativeVideoPlayer({
const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}`
: image[sheetIdx];
+ console.log(sheetUrl,'sheetUrlsheetUrl')
return (
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([]);
+ const heartbeatRef = useRef | 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;
+}
diff --git a/hooks/useLiveDetail.ts b/hooks/useLiveDetail.ts
index d81ba65..1dbab95 100644
--- a/hooks/useLiveDetail.ts
+++ b/hooks/useLiveDetail.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/bilibili';
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
@@ -33,7 +33,7 @@ export function useLiveDetail(roomId: number) {
]);
if (cancelled) return;
- let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0 };
+ let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
if (room.live_status === 1) {
stream = await getLiveStreamUrl(roomId);
}
@@ -50,5 +50,12 @@ export function useLiveDetail(roomId: number) {
return () => { cancelled = true; };
}, [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 };
}
diff --git a/hooks/useSearch.ts b/hooks/useSearch.ts
new file mode 100644
index 0000000..18131eb
--- /dev/null
+++ b/hooks/useSearch.ts
@@ -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([]);
+ 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 };
+}
diff --git a/hooks/useVideoList.ts b/hooks/useVideoList.ts
index ebb022f..16c558a 100644
--- a/hooks/useVideoList.ts
+++ b/hooks/useVideoList.ts
@@ -13,14 +13,17 @@ export function useVideoList() {
const freshIdxRef = useRef(0);
const load = useCallback(async (reset = false) => {
- if (loadingRef.current) return;
+ if (loadingRef.current) {
+ if (reset) setRefreshing(false);
+ return;
+ }
loadingRef.current = true;
- const idx = reset ? 0 : freshIdxRef.current;
+ const idx = freshIdxRef.current;
setLoading(true);
try {
const promises: [Promise, Promise] = [
getRecommendFeed(idx),
- reset || idx === 0
+ (reset || idx === 0)
? getLiveList(1, 0).catch(() => [] as LiveRoom[])
: Promise.resolve([] as LiveRoom[]),
];
@@ -42,11 +45,11 @@ export function useVideoList() {
}, []); // stable — no stale closure risk
const refresh = useCallback(() => {
+ console.log('Refreshing video list');
setRefreshing(true);
load(true);
}, [load]);
const videos = useMemo(() => pages.flat(), [pages]);
-
return { videos, pages, liveRooms, loading, refreshing, load, refresh };
}
diff --git a/services/bilibili.ts b/services/bilibili.ts
index bd97040..8ad5e9b 100644
--- a/services/bilibili.ts
+++ b/services/bilibili.ts
@@ -94,7 +94,6 @@ export async function getRecommendFeed(freshIdx = 0): Promise {
);
const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed });
const items: any[] = res.data.data?.item ?? [];
- console.log(items,'items')
return items
.filter(item => item.goto === 'av' && item.bvid && item.title)
.map(item => ({
@@ -259,15 +258,19 @@ export async function getLiveAnchorInfo(roomId: number): Promise
return { uid: info.uid, uname: info.uname, face: info.face } as LiveAnchorInfo;
}
-export async function getLiveStreamUrl(roomId: number): Promise {
+export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise {
try {
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 flvUrl = '';
- let qn = 0;
+ let currentQn = 0;
const hlsStream = streams.find(s => s.protocol_name === 'http_hls');
if (hlsStream) {
@@ -276,7 +279,7 @@ export async function getLiveStreamUrl(roomId: number): Promise
const urlInfo = codec?.url_info?.[0];
if (urlInfo) {
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
}
}
- return { hlsUrl, flvUrl, qn };
+ return { hlsUrl, flvUrl, qn: currentQn, qualities };
} 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 {
+ 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 {
try {
if (isWeb) {
diff --git a/services/types.ts b/services/types.ts
index 06eec01..66d116b 100644
--- a/services/types.ts
+++ b/services/types.ts
@@ -153,4 +153,5 @@ export interface LiveStreamInfo {
hlsUrl: string;
flvUrl: string;
qn: number;
+ qualities: { qn: number; desc: string }[];
}