feat: 视频推荐关联当前视频、UP主信息展示、直播全屏修复及UI优化

- 推荐列表改用 getVideoRelated 基于当前 bvid 推荐,与首页 feed 解耦
- 视频详情页 UP主名称下方展示粉丝数和视频数
- 推荐视频点击改为 router.replace 避免页面堆叠
- 直播全屏改用 position:absolute,解决退出全屏视频重建暂停问题
- 退出全屏时直播自动暂停
- 直播画质选中状态修复,过滤杜比/4K 画质选项
- 直播画质面板改为居中 Modal 弹出框
- 视频详情 Tab 按钮左对齐,评论排序及设置页按钮统一实心背景风格
This commit is contained in:
Developer
2026-03-25 12:03:28 +08:00
parent 7870c83b12
commit e1fa8cc347
8 changed files with 340 additions and 227 deletions

View File

@@ -5,7 +5,25 @@
--- ---
## [1.0.8] - 2026-03-24 ## [1.0.12] - 2026-03-25
### 新增
- **UP主信息**:视频详情页博主名称下方展示粉丝数和视频数(`getUploaderStat``/x/web-interface/card`
- **视频相关推荐**:详情页推荐列表改为基于当前视频(`getVideoRelated``/x/web-interface/archive/related`),不再与首页 feed 共用
### 修复
- **直播全屏退出暂停**:全屏改用 `position:absolute` 覆盖Video 组件始终在同一棵 React 树中,不再因 Modal 切换导致重建暂停;退出全屏时直播自动暂停
- **直播画质选中**`changeQuality` 强制用请求的 `qn` 覆盖服务端协商值,画质面板高亮与用户选择一致
- **直播画质过滤**:过滤 `qn > 10000` 的选项(杜比/4K最高仅展示原画
- **推荐视频导航**:点击推荐列表改用 `router.replace`,避免详情页无限堆叠
### 优化
- **直播画质面板**:改为居中 Modal 弹出框
- **视频详情 Tab**:按钮向左靠齐,移除均分宽度
- **评论排序按钮**:统一为实心背景风格(`#f0f0f0``#00AEEC`),与直播分区 Tab 一致
- **设置页按钮**:外观/流量选项按钮统一为实心背景风格
## [1.0.11] - 2026-03-24
### 新增 ### 新增
- **暗黑模式**:全局主题系统(`utils/theme.ts`),支持亮色 / 暗色一键切换,覆盖所有页面和组件 - **暗黑模式**:全局主题系统(`utils/theme.ts`),支持亮色 / 暗色一键切换,覆盖所有页面和组件

View File

@@ -140,16 +140,14 @@ const styles = StyleSheet.create({
sectionLabel: { fontSize: 13, marginBottom: 10 }, sectionLabel: { fontSize: 13, marginBottom: 10 },
optionRow: { flexDirection: 'row', gap: 10 }, optionRow: { flexDirection: 'row', gap: 10 },
option: { option: {
paddingHorizontal: 20, paddingHorizontal: 10,
paddingVertical: 6, paddingVertical: 2,
borderRadius: 20, borderRadius: 16,
borderWidth: 1, backgroundColor: '#f0f0f0',
borderColor: '#e0e0e0',
backgroundColor: 'transparent',
}, },
optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' }, optionActive: { backgroundColor: '#00AEEC' },
optionText: { fontSize: 14, color: '#666' }, optionText: { fontSize: 13, color: '#333', fontWeight: '500' },
optionTextActive: { color: '#00AEEC', fontWeight: '600' }, optionTextActive: { color: '#fff', fontWeight: '600' },
hint: { fontSize: 12, marginTop: 8 }, hint: { fontSize: 12, marginTop: 8 },
versionRow: { versionRow: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -13,7 +13,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { VideoPlayer } from "../../components/VideoPlayer"; import { VideoPlayer } from "../../components/VideoPlayer";
import { CommentItem } from "../../components/CommentItem"; import { CommentItem } from "../../components/CommentItem";
import { getDanmaku } from "../../services/bilibili"; import { getDanmaku, getUploaderStat } from "../../services/bilibili";
import { DanmakuItem } from "../../services/types"; import { DanmakuItem } from "../../services/types";
import DanmakuList from "../../components/DanmakuList"; import DanmakuList from "../../components/DanmakuList";
import { useVideoDetail } from "../../hooks/useVideoDetail"; import { useVideoDetail } from "../../hooks/useVideoDetail";
@@ -49,11 +49,12 @@ export default function VideoDetailScreen() {
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]); const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [showDownload, setShowDownload] = useState(false); const [showDownload, setShowDownload] = useState(false);
const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number } | null>(null);
const { const {
videos: relatedVideos, videos: relatedVideos,
loading: relatedLoading, loading: relatedLoading,
load: loadRelated, load: loadRelated,
} = useRelatedVideos(); } = useRelatedVideos(bvid as string);
useEffect(() => { useEffect(() => {
loadRelated(); loadRelated();
@@ -68,6 +69,11 @@ export default function VideoDetailScreen() {
getDanmaku(video.cid).then(setDanmakus); getDanmaku(video.cid).then(setDanmakus);
}, [video?.cid]); }, [video?.cid]);
useEffect(() => {
if (!video?.owner?.mid) return;
getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {});
}, [video?.owner?.mid]);
return ( return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}> <SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
{/* TopBar */} {/* TopBar */}
@@ -178,10 +184,6 @@ export default function VideoDetailScreen() {
data={relatedVideos} data={relatedVideos}
keyExtractor={(item) => item.bvid} keyExtractor={(item) => item.bvid}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onEndReached={() => {
if (!relatedLoading) loadRelated();
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ ListHeaderComponent={
<> <>
<View style={styles.upRow}> <View style={styles.upRow}>
@@ -189,9 +191,16 @@ export default function VideoDetailScreen() {
source={{ uri: proxyImageUrl(video.owner.face) }} source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar} style={styles.avatar}
/> />
<Text style={[styles.upName, { color: theme.text }]}> <View style={styles.upInfo}>
{video.owner.name} <Text style={[styles.upName, { color: theme.text }]}>
</Text> {video.owner.name}
</Text>
{uploaderStat && (
<Text style={styles.upStat}>
{formatCount(uploaderStat.follower)} · {formatCount(uploaderStat.archiveCount)}
</Text>
)}
</View>
<TouchableOpacity style={styles.followBtn}> <TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text> <Text style={styles.followTxt}>+ </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -249,7 +258,7 @@ export default function VideoDetailScreen() {
borderBottomColor: theme.border, borderBottomColor: theme.border,
}, },
]} ]}
onPress={() => router.push(`/video/${item.bvid}` as any)} onPress={() => router.replace(`/video/${item.bvid}` as any)}
activeOpacity={0.85} activeOpacity={0.85}
> >
<View <View
@@ -321,7 +330,6 @@ export default function VideoDetailScreen() {
<View <View
style={[styles.sortRow, { borderBottomColor: theme.border }]} style={[styles.sortRow, { borderBottomColor: theme.border }]}
> >
<Text style={styles.sortLabel}></Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.sortBtn, styles.sortBtn,
@@ -447,7 +455,7 @@ function SeasonSection({
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
getItemLayout={(_data, index) => ({ getItemLayout={(_data, index) => ({
length: 130, length: 130,
offset: 12 + index * 140, offset: 12 + index * 130,
index, index,
})} })}
onScrollToIndexFailed={() => {}} onScrollToIndexFailed={() => {}}
@@ -528,8 +536,10 @@ const styles = StyleSheet.create({
paddingBottom: 0, paddingBottom: 0,
paddingTop: 12, paddingTop: 12,
}, },
avatar: { width: 48, height: 48, borderRadius: 30, marginRight: 10 }, avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 },
upName: { flex: 1, fontSize: 14, fontWeight: "500" }, upInfo: { flex: 1, justifyContent: "center" },
upName: { fontSize: 14, fontWeight: "500" },
upStat: { fontSize: 11, color: "#999", marginTop: 2 },
followBtn: { followBtn: {
backgroundColor: "#00AEEC", backgroundColor: "#00AEEC",
paddingHorizontal: 10, paddingHorizontal: 10,
@@ -570,11 +580,12 @@ const styles = StyleSheet.create({
tabBar: { tabBar: {
flexDirection: "row", flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
paddingLeft: 3,
}, },
tabItem: { tabItem: {
flex: 1,
alignItems: "center", alignItems: "center",
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 12,
position: "relative", position: "relative",
}, },
tabLabel: { fontSize: 13 }, tabLabel: { fontSize: 13 },
@@ -593,7 +604,7 @@ const styles = StyleSheet.create({
danmakuTab: { flex: 1 }, danmakuTab: { flex: 1 },
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
relatedHeader: { relatedHeader: {
paddingLeft: 12, paddingLeft: 13,
paddingBottom: 8, paddingBottom: 8,
paddingTop: 8, paddingTop: 8,
}, },
@@ -643,15 +654,13 @@ const styles = StyleSheet.create({
gap: 8, gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
sortLabel: { fontSize: 13, color: "#999", marginRight: 4 },
sortBtn: { sortBtn: {
paddingHorizontal: 14, paddingHorizontal: 10,
paddingVertical: 3, paddingVertical: 2,
borderRadius: 20, borderRadius: 16,
borderWidth: 1, backgroundColor: "#f0f0f0",
borderColor: "#e0e0e0",
}, },
sortBtnActive: { borderColor: "#00AEEC", backgroundColor: "#e8f7fd" }, sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 12, color: "#666" }, sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
sortBtnTxtActive: { color: "#00AEEC", fontWeight: "600" as const }, sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },
}); });

View File

@@ -211,58 +211,81 @@ export default function DanmakuList({
}, []); }, []);
// ─── Live mode render (B站-style chat) ───────────────────────────────────── // ─── Live mode render (B站-style chat) ─────────────────────────────────────
const renderLiveItem = useCallback(({ item }: { item: DisplayedDanmaku }) => { const renderLiveItem = useCallback(
const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null; ({ item }: { item: DisplayedDanmaku }) => {
const timeStr = formatLiveTime(item.timeline); const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null;
return ( const timeStr = formatLiveTime(item.timeline);
<Animated.View style={[liveStyles.row, { opacity: item._fadeAnim, borderBottomColor: theme.border }]}> return (
{timeStr ? ( <Animated.View
<Text style={liveStyles.time}>{timeStr}</Text> style={[
) : null} liveStyles.row,
<View style={liveStyles.msgBody}> { opacity: item._fadeAnim, borderBottomColor: theme.border },
{guard && ( ]}
<View style={[liveStyles.guardTag, { backgroundColor: guard.color }]}> >
<Text style={liveStyles.guardTagText}>{guard.text}</Text> {timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
</View> <View style={liveStyles.msgBody}>
)} {guard && (
{item.isAdmin && ( <View
<View style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}> style={[liveStyles.guardTag, { backgroundColor: guard.color }]}
<Text style={liveStyles.guardTagText}></Text> >
</View> <Text style={liveStyles.guardTagText}>{guard.text}</Text>
)}
{item.medalLevel != null && item.medalName && (
<View style={liveStyles.medalTag}>
<Text style={liveStyles.medalName}>{item.medalName}</Text>
<View style={liveStyles.medalLvBox}>
<Text style={liveStyles.medalLv}>{item.medalLevel}</Text>
</View> </View>
</View> )}
)} {item.isAdmin && (
<Text style={liveStyles.uname} numberOfLines={1}> <View
{item.uname ?? "匿名"} style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}
</Text> >
<Text style={liveStyles.colon}></Text> <Text style={liveStyles.guardTagText}></Text>
<Text style={[liveStyles.text, { color: theme.text }]} numberOfLines={2}> </View>
{item.text} )}
</Text> {item.medalLevel != null && item.medalName && (
</View> <View style={liveStyles.medalTag}>
</Animated.View> <Text style={liveStyles.medalName}>{item.medalName}</Text>
); <View style={liveStyles.medalLvBox}>
}, [theme]); <Text style={liveStyles.medalLv}>{item.medalLevel}</Text>
</View>
</View>
)}
<Text style={liveStyles.uname} numberOfLines={1}>
{item.uname ?? "匿名"}
</Text>
<Text style={liveStyles.colon}></Text>
<Text
style={[liveStyles.text, { color: theme.text }]}
numberOfLines={2}
>
{item.text}
</Text>
</View>
</Animated.View>
);
},
[theme],
);
// ─── Video mode render (original bubble) ─────────────────────────────────── // ─── Video mode render (original bubble) ───────────────────────────────────
const renderVideoItem = useCallback(({ item }: { item: DisplayedDanmaku }) => { const renderVideoItem = useCallback(
const dotColor = danmakuColorToCss(item.color); ({ item }: { item: DisplayedDanmaku }) => {
return ( const dotColor = danmakuColorToCss(item.color);
<Animated.View style={[styles.bubble, { opacity: item._fadeAnim, backgroundColor: theme.bg }]}> return (
<View style={[styles.colorDot, { backgroundColor: dotColor }]} /> <Animated.View
<Text style={[styles.bubbleText, { color: theme.text }]} numberOfLines={3}> style={[
{item.text} styles.bubble,
</Text> { opacity: item._fadeAnim, backgroundColor: theme.bg },
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text> ]}
</Animated.View> >
); <Text
}, [theme]); style={[styles.bubbleText, { color: dotColor }]}
numberOfLines={3}
>
{item.text}
</Text>
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
</Animated.View>
);
},
[theme],
);
const keyExtractor = useCallback( const keyExtractor = useCallback(
(item: DisplayedDanmaku) => String(item._key), (item: DisplayedDanmaku) => String(item._key),
@@ -270,7 +293,13 @@ export default function DanmakuList({
); );
return ( return (
<View style={[styles.container, { backgroundColor: theme.card, borderTopColor: theme.border }, style]}> <View
style={[
styles.container,
{ backgroundColor: theme.card, borderTopColor: theme.border },
style,
]}
>
{!hideHeader && ( {!hideHeader && (
<TouchableOpacity <TouchableOpacity
style={styles.header} style={styles.header}
@@ -300,8 +329,13 @@ export default function DanmakuList({
data={displayedItems} data={displayedItems}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={isLive ? renderLiveItem : renderVideoItem} renderItem={isLive ? renderLiveItem : renderVideoItem}
style={[isLive ? liveStyles.list : styles.list, { backgroundColor: theme.bg }]} style={[
contentContainerStyle={isLive ? liveStyles.listContent : styles.listContent} isLive ? liveStyles.list : styles.list,
{ backgroundColor: theme.card },
]}
contentContainerStyle={
isLive ? liveStyles.listContent : styles.listContent
}
onScroll={handleScroll} onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag} onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16} scrollEventThrottle={16}
@@ -323,7 +357,6 @@ export default function DanmakuList({
)} )}
</View> </View>
)} )}
</View> </View>
); );
} }
@@ -370,13 +403,6 @@ const styles = StyleSheet.create({
marginVertical: 2, marginVertical: 2,
gap: 8, gap: 8,
}, },
colorDot: {
width: 6,
height: 6,
borderRadius: 3,
marginTop: 6,
flexShrink: 0,
},
bubbleText: { bubbleText: {
flex: 1, flex: 1,
fontSize: 13, fontSize: 13,
@@ -425,8 +451,6 @@ const liveStyles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
alignItems: "flex-start", alignItems: "flex-start",
paddingVertical: 5, paddingVertical: 5,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#f0f0f2",
}, },
time: { time: {
fontSize: 10, fontSize: 10,

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from "react";
import { import {
View, View,
Text, Text,
@@ -8,8 +8,8 @@ import {
Modal, Modal,
Platform, Platform,
useWindowDimensions, useWindowDimensions,
} from 'react-native'; } from "react-native";
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from "@expo/vector-icons";
interface Props { interface Props {
hlsUrl: string; hlsUrl: string;
@@ -23,16 +23,23 @@ interface Props {
const HIDE_DELAY = 3000; const HIDE_DELAY = 3000;
const HEADERS = { const HEADERS = {
Referer: 'https://live.bilibili.com', Referer: "https://live.bilibili.com",
'User-Agent': "User-Agent":
'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, flvUrl, isLive, qualities = [], currentQn = 0, onQualityChange }: Props) { export function LivePlayer({
hlsUrl,
flvUrl,
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;
if (Platform.OS === 'web') { if (Platform.OS === "web") {
return ( return (
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}> <View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
<Text style={styles.webHint}></Text> <Text style={styles.webHint}></Text>
@@ -79,7 +86,7 @@ function NativeLivePlayer({
currentQn: number; currentQn: number;
onQualityChange?: (qn: number) => void; onQualityChange?: (qn: number) => void;
}) { }) {
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);
@@ -97,14 +104,16 @@ function NativeLivePlayer({
useEffect(() => { useEffect(() => {
resetHideTimer(); resetHideTimer();
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []); }, []);
// Lock/unlock orientation on fullscreen toggle // Lock/unlock orientation on fullscreen toggle
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const ScreenOrientation = require('expo-screen-orientation'); const ScreenOrientation = require("expo-screen-orientation");
if (isFullscreen) { if (isFullscreen) {
await ScreenOrientation.lockAsync( await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
@@ -114,7 +123,9 @@ function NativeLivePlayer({
ScreenOrientation.OrientationLock.PORTRAIT_UP, ScreenOrientation.OrientationLock.PORTRAIT_UP,
); );
} }
} catch { /* graceful degradation in Expo Go */ } } catch {
/* graceful degradation in Expo Go */
}
})(); })();
}, [isFullscreen]); }, [isFullscreen]);
@@ -123,18 +134,23 @@ function NativeLivePlayer({
return () => { return () => {
(async () => { (async () => {
try { try {
const ScreenOrientation = require('expo-screen-orientation'); const ScreenOrientation = require("expo-screen-orientation");
await ScreenOrientation.lockAsync( await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP, ScreenOrientation.OrientationLock.PORTRAIT_UP,
); );
} catch { /* ignore */ } } catch {
/* ignore */
}
})(); })();
}; };
}, []); }, []);
const handleTap = useCallback(() => { const handleTap = useCallback(() => {
setShowControls(prev => { setShowControls((prev) => {
if (!prev) { resetHideTimer(); return true; } if (!prev) {
resetHideTimer();
return true;
}
if (hideTimer.current) clearTimeout(hideTimer.current); if (hideTimer.current) clearTimeout(hideTimer.current);
return false; return false;
}); });
@@ -143,23 +159,27 @@ function NativeLivePlayer({
const fsW = Math.max(screenW, screenH); const fsW = Math.max(screenW, screenH);
const fsH = Math.min(screenW, screenH); const fsH = Math.min(screenW, screenH);
const containerStyle = isFullscreen const containerStyle = isFullscreen
? { width: fsW, height: fsH } ? { position: 'absolute' as const, top: 0, left: 0, width: fsW, height: fsH, zIndex: 999, elevation: 999 }
: { width: screenW, height: videoH }; : { width: screenW, height: videoH };
const currentQnDesc = qualities.find(q => q.qn === currentQn)?.desc ?? ''; 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
key={hlsUrl + (isFullscreen ? '_fs' : '_normal')} key={hlsUrl}
ref={videoRef} ref={videoRef}
source={{ uri: hlsUrl, headers: HEADERS }} source={{ uri: hlsUrl, headers: HEADERS }}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
resizeMode="contain" resizeMode="contain"
controls={false} controls={false}
paused={paused} paused={paused}
onProgress={({ currentTime: ct }: { currentTime: number }) => { currentTimeRef.current = ct; }} onProgress={({ currentTime: ct }: { currentTime: number }) => {
onBuffer={({ isBuffering }: { isBuffering: boolean }) => setBuffering(isBuffering)} currentTimeRef.current = ct;
}}
onBuffer={({ isBuffering }: { isBuffering: boolean }) =>
setBuffering(isBuffering)
}
onLoad={() => { onLoad={() => {
setBuffering(false); setBuffering(false);
if (currentTimeRef.current > 0) { if (currentTimeRef.current > 0) {
@@ -183,10 +203,17 @@ function NativeLivePlayer({
{/* Center play/pause */} {/* Center play/pause */}
<TouchableOpacity <TouchableOpacity
style={styles.centerBtn} style={styles.centerBtn}
onPress={() => { setPaused(p => !p); resetHideTimer(); }} onPress={() => {
setPaused((p) => !p);
resetHideTimer();
}}
> >
<View style={styles.centerBtnBg}> <View style={styles.centerBtnBg}>
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" /> <Ionicons
name={paused ? "play" : "pause"}
size={28}
color="#fff"
/>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -194,93 +221,117 @@ function NativeLivePlayer({
<View style={styles.bottomBar}> <View style={styles.bottomBar}>
<TouchableOpacity <TouchableOpacity
style={styles.ctrlBtn} style={styles.ctrlBtn}
onPress={() => { setPaused(p => !p); resetHideTimer(); }} onPress={() => {
setPaused((p) => !p);
resetHideTimer();
}}
> >
<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 && ( {qualities.length > 0 && (
<TouchableOpacity <TouchableOpacity
style={styles.qualityBtn} style={styles.qualityBtn}
onPress={() => { setShowQualityPanel(true); resetHideTimer(); }} onPress={() => {
setShowQualityPanel(true);
resetHideTimer();
}}
> >
<Text style={styles.qualityText}>{currentQnDesc || '清晰度'}</Text> <Text style={styles.qualityText}>
{currentQnDesc || "清晰度"}
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
style={styles.ctrlBtn} style={styles.ctrlBtn}
onPress={() => { setIsFullscreen(fs => !fs); resetHideTimer(); }} onPress={() => {
if (isFullscreen) setPaused(true);
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 */} {/* Quality selector panel */}
{showQualityPanel && ( <Modal
<TouchableWithoutFeedback onPress={() => setShowQualityPanel(false)}> visible={showQualityPanel}
<View style={styles.qualityOverlay}> transparent
<TouchableWithoutFeedback> animationType="fade"
<View style={styles.qualityPanel}> onRequestClose={() => setShowQualityPanel(false)}
<Text style={styles.qualityPanelTitle}></Text> >
{qualities.map(q => ( <TouchableOpacity
<TouchableOpacity style={styles.qualityOverlay}
key={q.qn} activeOpacity={1}
style={[styles.qualityItem, currentQn === q.qn && styles.qualityItemActive]} onPress={() => setShowQualityPanel(false)}
onPress={() => { >
onQualityChange?.(q.qn); <TouchableOpacity activeOpacity={1} onPress={() => {}}>
setShowQualityPanel(false); <View style={styles.qualityPanel}>
}} <Text style={styles.qualityPanelTitle}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={[styles.qualityItem]}
onPress={() => {
onQualityChange?.(q.qn);
setShowQualityPanel(false);
}}
>
<Text
style={[
styles.qualityItemText,
currentQn === q.qn && styles.qualityItemTextActive,
]}
> >
<Text style={[styles.qualityItemText, currentQn === q.qn && styles.qualityItemTextActive]}> {q.desc}
{q.desc} </Text>
</Text> {currentQn === q.qn && (
{currentQn === q.qn && ( <Ionicons name="checkmark" size={14} color="#00AEEC" />
<Ionicons name="checkmark" size={14} color="#00AEEC" /> )}
)} </TouchableOpacity>
</TouchableOpacity> ))}
))} </View>
</View> </TouchableOpacity>
</TouchableWithoutFeedback> </TouchableOpacity>
</View> </Modal>
</TouchableWithoutFeedback>
)}
</View> </View>
); );
if (isFullscreen) {
return (
<Modal visible transparent animationType="fade" supportedOrientations={['landscape']} onRequestClose={() => setIsFullscreen(false)}>
<View style={styles.fsModal}>{videoContent}</View>
</Modal>
);
}
return videoContent; return videoContent;
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: '#000', backgroundColor: "#000",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}, },
webHint: { color: '#fff', fontSize: 15 }, webHint: { color: "#fff", fontSize: 15 },
offlineText: { color: '#999', fontSize: 14, marginTop: 10 }, offlineText: { color: "#999", fontSize: 14, marginTop: 10 },
bufferingOverlay: { bufferingOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}, },
bufferingText: { color: '#fff', fontSize: 13, opacity: 0.8 }, bufferingText: { color: "#fff", fontSize: 13, opacity: 0.8 },
liveBadge: { liveBadge: {
position: 'absolute', position: "absolute",
top: 10, top: 10,
left: 12, left: 12,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: "rgba(0,0,0,0.5)",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 3,
borderRadius: 4, borderRadius: 4,
@@ -289,79 +340,78 @@ const styles = StyleSheet.create({
width: 6, width: 6,
height: 6, height: 6,
borderRadius: 3, borderRadius: 3,
backgroundColor: '#f00', backgroundColor: "#f00",
marginRight: 5, marginRight: 5,
}, },
liveText: { color: '#fff', fontSize: 11, fontWeight: '700', letterSpacing: 1 }, liveText: {
color: "#fff",
fontSize: 11,
fontWeight: "700",
letterSpacing: 1,
},
centerBtn: { centerBtn: {
position: 'absolute', position: "absolute",
top: '50%', top: "50%",
left: '50%', left: "50%",
transform: [{ translateX: -28 }, { translateY: -28 }], transform: [{ translateX: -28 }, { translateY: -28 }],
}, },
centerBtnBg: { centerBtnBg: {
width: 56, width: 56,
height: 56, height: 56,
borderRadius: 28, borderRadius: 28,
backgroundColor: 'rgba(0,0,0,0.45)', backgroundColor: "rgba(0,0,0,0.45)",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}, },
bottomBar: { bottomBar: {
position: 'absolute', position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingBottom: 8, paddingBottom: 8,
paddingTop: 24, paddingTop: 24,
backgroundColor: 'rgba(0,0,0,0)', backgroundColor: "rgba(0,0,0,0)",
}, },
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 }, ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
qualityBtn: { qualityBtn: {
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
backgroundColor: 'rgba(0,0,0,0.4)', backgroundColor: "rgba(0,0,0,0.4)",
borderRadius: 4, borderRadius: 4,
marginRight: 4, marginRight: 4,
}, },
qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' }, qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
qualityOverlay: { qualityOverlay: {
...StyleSheet.absoluteFillObject, flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: 'flex-end', justifyContent: "center",
alignItems: "center",
}, },
qualityPanel: { qualityPanel: {
backgroundColor: 'rgba(20,20,20,0.95)', backgroundColor: "#fff",
borderTopLeftRadius: 12, borderRadius: 12,
borderTopRightRadius: 12, paddingVertical: 8,
paddingBottom: 16, paddingHorizontal: 16,
minWidth: 180,
}, },
qualityPanelTitle: { qualityPanelTitle: {
color: '#fff', fontSize: 15,
fontSize: 13, fontWeight: "700",
fontWeight: '600', color: "#212121",
textAlign: 'center', paddingVertical: 10,
paddingVertical: 12, textAlign: "center",
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(255,255,255,0.1)',
}, },
qualityItem: { qualityItem: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 12, paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#eee",
}, },
qualityItemActive: { backgroundColor: 'rgba(0,174,236,0.15)' }, qualityItemText: { fontSize: 14, color: "#333" },
qualityItemText: { color: '#ccc', fontSize: 14 }, qualityItemTextActive: { color: "#00AEEC", fontWeight: "700" },
qualityItemTextActive: { color: '#00AEEC', fontWeight: '600' },
fsModal: {
flex: 1,
backgroundColor: '#000',
alignItems: 'center',
justifyContent: 'center',
},
}); });

View File

@@ -53,7 +53,7 @@ export function useLiveDetail(roomId: number) {
const changeQuality = useCallback(async (qn: number) => { const changeQuality = useCallback(async (qn: number) => {
try { try {
const stream = await getLiveStreamUrl(roomId, qn); const stream = await getLiveStreamUrl(roomId, qn);
setState(prev => ({ ...prev, stream })); setState(prev => ({ ...prev, stream: { ...stream, qn } }));
} catch { /* ignore */ } } catch { /* ignore */ }
}, [roomId]); }, [roomId]);

View File

@@ -1,29 +1,26 @@
import { useState, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useRef } from 'react';
import { getRecommendFeed } from '../services/bilibili'; import { getVideoRelated } from '../services/bilibili';
import type { VideoItem } from '../services/types'; import type { VideoItem } from '../services/types';
export function useRelatedVideos() { export function useRelatedVideos(bvid: string) {
const [pages, setPages] = useState<VideoItem[][]>([]); const [videos, setVideos] = useState<VideoItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const freshIdxRef = useRef(0);
const load = useCallback(async () => { const load = useCallback(async () => {
if (loadingRef.current) return; if (loadingRef.current) return;
loadingRef.current = true; loadingRef.current = true;
setLoading(true); setLoading(true);
try { try {
const data = await getRecommendFeed(freshIdxRef.current); const data = await getVideoRelated(bvid);
setPages(prev => [...prev, data]); setVideos(data);
freshIdxRef.current += 1;
} catch (e) { } catch (e) {
console.warn('useRelatedVideos: failed', e); console.warn('useRelatedVideos: failed', e);
} finally { } finally {
loadingRef.current = false; loadingRef.current = false;
setLoading(false); setLoading(false);
} }
}, []); }, [bvid]);
const videos = useMemo(() => pages.flat(), [pages]); return { videos, loading, load, hasMore: false };
return { videos, loading, load };
} }

View File

@@ -114,6 +114,12 @@ export async function getVideoDetail(bvid: string): Promise<VideoItem> {
return res.data.data as VideoItem; return res.data.data as VideoItem;
} }
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
const res = await api.get('/x/web-interface/archive/related', { params: { bvid } });
const items: any[] = res.data.data ?? [];
return items as VideoItem[];
}
export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> { export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
const isAndroid = Platform.OS === 'android'; const isAndroid = Platform.OS === 'android';
// 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界) // 1488 = 16(DASH)|64(HDR)|128(4K)|256(杜比全景声)|1024(杜比视界)
@@ -143,6 +149,15 @@ export async function getPlayUrlForDownload(
return url; return url;
} }
export async function getUploaderStat(mid: number): Promise<{ follower: number; archiveCount: number }> {
const res = await api.get('/x/web-interface/card', { params: { mid } });
const data = res.data.data ?? {};
return {
follower: data.follower ?? 0,
archiveCount: data.archive_count ?? 0,
};
}
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> { export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
const res = await api.get('/x/web-interface/nav'); const res = await api.get('/x/web-interface/nav');
const { face, uname, mid } = res.data.data; const { face, uname, mid } = res.data.data;
@@ -286,7 +301,9 @@ export async function getLiveStreamUrl(roomId: number, qn = 10000): Promise<Live
const playurl = res.data?.data?.playurl_info?.playurl; const playurl = res.data?.data?.playurl_info?.playurl;
const streams: any[] = playurl?.stream ?? []; const streams: any[] = playurl?.stream ?? [];
const gQnDesc: any[] = playurl?.g_qn_desc ?? []; const gQnDesc: any[] = playurl?.g_qn_desc ?? [];
const qualities = gQnDesc.map((q: any) => ({ qn: q.qn as number, desc: q.desc as string })); const qualities = gQnDesc
.map((q: any) => ({ qn: q.qn as number, desc: q.desc as string }))
.filter(q => q.qn <= 10000);
let hlsUrl = ''; let hlsUrl = '';
let flvUrl = ''; let flvUrl = '';