mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
feat: 视频推荐关联当前视频、UP主信息展示、直播全屏修复及UI优化
- 推荐列表改用 getVideoRelated 基于当前 bvid 推荐,与首页 feed 解耦 - 视频详情页 UP主名称下方展示粉丝数和视频数 - 推荐视频点击改为 router.replace 避免页面堆叠 - 直播全屏改用 position:absolute,解决退出全屏视频重建暂停问题 - 退出全屏时直播自动暂停 - 直播画质选中状态修复,过滤杜比/4K 画质选项 - 直播画质面板改为居中 Modal 弹出框 - 视频详情 Tab 按钮左对齐,评论排序及设置页按钮统一实心背景风格
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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`),支持亮色 / 暗色一键切换,覆盖所有页面和组件
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user