From a46e63f0bae23c8e19ce26bf3fad69b9b8ced654 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 16 Mar 2026 21:13:26 +0800 Subject: [PATCH] 1 --- app/_layout.tsx | 7 ++ app/index.tsx | 112 +++++++++++-------- app/live/[roomId].tsx | 134 ++++++++++++++--------- app/search.tsx | 181 +++++++++++++++++++++++++++++++ app/video/[bvid].tsx | 16 +-- components/LivePlayer.tsx | 141 +++++++++++++++++++++++- components/NativeVideoPlayer.tsx | 3 +- hooks/useLiveDanmaku.ts | 96 ++++++++++++++++ hooks/useLiveDetail.ts | 13 ++- hooks/useSearch.ts | 42 +++++++ hooks/useVideoList.ts | 11 +- services/bilibili.ts | 57 ++++++++-- services/types.ts | 1 + 13 files changed, 681 insertions(+), 133 deletions(-) create mode 100644 app/search.tsx create mode 100644 hooks/useLiveDanmaku.ts create mode 100644 hooks/useSearch.ts 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 = ( ); @@ -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 }[]; }