mirror of
https://github.com/tiajinsha/JKVideo.git
synced 2026-05-07 22:27:06 +08:00
1
This commit is contained in:
@@ -118,6 +118,7 @@ export default function HomeScreen() {
|
||||
}
|
||||
onEndReached={() => load()}
|
||||
onEndReachedThreshold={0.5}
|
||||
extraData={visibleBigKey}
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
onViewableItemsChanged={onViewableItemsChangedRef}
|
||||
ListFooterComponent={
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View, Text, ScrollView, StyleSheet,
|
||||
TouchableOpacity, Image, ActivityIndicator
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { VideoPlayer } from '../../components/VideoPlayer';
|
||||
import { CommentItem } from '../../components/CommentItem';
|
||||
import { getDanmaku } from '../../services/bilibili';
|
||||
import { DanmakuItem } from '../../services/types';
|
||||
import DanmakuList from '../../components/DanmakuList';
|
||||
import { useVideoDetail } from '../../hooks/useVideoDetail';
|
||||
import { useComments } from '../../hooks/useComments';
|
||||
import { useVideoStore } from '../../store/videoStore';
|
||||
import { formatCount } from '../../utils/format';
|
||||
import { proxyImageUrl } from '../../utils/imageUrl';
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { VideoPlayer } from "../../components/VideoPlayer";
|
||||
import { CommentItem } from "../../components/CommentItem";
|
||||
import { getDanmaku } from "../../services/bilibili";
|
||||
import { DanmakuItem } from "../../services/types";
|
||||
import DanmakuList from "../../components/DanmakuList";
|
||||
import { useVideoDetail } from "../../hooks/useVideoDetail";
|
||||
import { useComments } from "../../hooks/useComments";
|
||||
import { useVideoStore } from "../../store/videoStore";
|
||||
import { formatCount } from "../../utils/format";
|
||||
import { proxyImageUrl } from "../../utils/imageUrl";
|
||||
|
||||
type Tab = 'intro' | 'comments';
|
||||
type Tab = "intro" | "comments";
|
||||
|
||||
export default function VideoDetailScreen() {
|
||||
const { bvid } = useLocalSearchParams<{ bvid: string }>();
|
||||
const router = useRouter();
|
||||
const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string);
|
||||
const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0);
|
||||
const [tab, setTab] = useState<Tab>('comments');
|
||||
const {
|
||||
video,
|
||||
playData,
|
||||
loading: videoLoading,
|
||||
qualities,
|
||||
currentQn,
|
||||
changeQuality,
|
||||
} = useVideoDetail(bvid as string);
|
||||
const {
|
||||
comments,
|
||||
loading: cmtLoading,
|
||||
load: loadComments,
|
||||
} = useComments(video?.aid ?? 0);
|
||||
const [tab, setTab] = useState<Tab>("comments");
|
||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [showDanmakuList, setShowDanmakuList] = useState(true);
|
||||
@@ -56,7 +72,9 @@ export default function VideoDetailScreen() {
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color="#212121" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.topTitle} numberOfLines={1}>{video?.title ?? '视频详情'}</Text>
|
||||
<Text style={styles.topTitle} numberOfLines={1}>
|
||||
{video?.title ?? "视频详情"}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.miniBtn} onPress={handleMiniPlayer}>
|
||||
<Ionicons name="copy-outline" size={22} color="#212121" />
|
||||
</TouchableOpacity>
|
||||
@@ -73,12 +91,11 @@ export default function VideoDetailScreen() {
|
||||
danmakus={danmakus}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
/>
|
||||
|
||||
<DanmakuList
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
visible={showDanmakuList}
|
||||
onToggle={() => setShowDanmakuList(v => !v)}
|
||||
onToggle={() => setShowDanmakuList((v) => !v)}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
@@ -97,7 +114,10 @@ export default function VideoDetailScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.upRow}>
|
||||
<Image source={{ uri: proxyImageUrl(video.owner.face) }} style={styles.avatar} />
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(video.owner.face) }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<Text style={styles.upName}>{video.owner.name}</Text>
|
||||
<TouchableOpacity style={styles.followBtn}>
|
||||
<Text style={styles.followTxt}>+ 关注</Text>
|
||||
@@ -105,28 +125,51 @@ export default function VideoDetailScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.tabBar}>
|
||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab('intro')}>
|
||||
<Text style={[styles.tabLabel, tab === 'intro' && styles.tabActive]}>简介</Text>
|
||||
{tab === 'intro' && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.tabItem} onPress={() => setTab('comments')}>
|
||||
<Text style={[styles.tabLabel, tab === 'comments' && styles.tabActive]}>
|
||||
评论 {video.stat.reply > 0 ? formatCount(video.stat.reply) : ''}
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("intro")}
|
||||
>
|
||||
<Text
|
||||
style={[styles.tabLabel, tab === "intro" && styles.tabActive]}
|
||||
>
|
||||
简介
|
||||
</Text>
|
||||
{tab === 'comments' && <View style={styles.tabUnderline} />}
|
||||
{tab === "intro" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab("comments")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
tab === "comments" && styles.tabActive,
|
||||
]}
|
||||
>
|
||||
评论{" "}
|
||||
{video.stat.reply > 0 ? formatCount(video.stat.reply) : ""}
|
||||
</Text>
|
||||
{tab === "comments" && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{tab === 'intro' ? (
|
||||
{tab === "intro" ? (
|
||||
<View style={styles.descBox}>
|
||||
<Text style={styles.descText}>{video.desc || '暂无简介'}</Text>
|
||||
<Text style={styles.descText}>{video.desc || "暂无简介"}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{comments.map(c => <CommentItem key={c.rpid} item={c} />)}
|
||||
{cmtLoading && <ActivityIndicator style={styles.loader} color="#00AEEC" />}
|
||||
{comments.map((c) => (
|
||||
<CommentItem key={c.rpid} item={c} />
|
||||
))}
|
||||
{cmtLoading && (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
)}
|
||||
{!cmtLoading && comments.length > 0 && (
|
||||
<TouchableOpacity style={styles.loadMore} onPress={loadComments}>
|
||||
<TouchableOpacity
|
||||
style={styles.loadMore}
|
||||
onPress={loadComments}
|
||||
>
|
||||
<Text style={styles.loadMoreTxt}>加载更多评论</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -152,31 +195,78 @@ function StatBadge({ icon, count }: { icon: string; count: number }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: '#fff' },
|
||||
topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
safe: { flex: 1, backgroundColor: "#fff" },
|
||||
topBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: "#eee",
|
||||
},
|
||||
backBtn: { padding: 4 },
|
||||
topTitle: { flex: 1, fontSize: 15, fontWeight: '600', marginLeft: 4, color: '#212121' },
|
||||
topTitle: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
marginLeft: 4,
|
||||
color: "#212121",
|
||||
},
|
||||
miniBtn: { padding: 4 },
|
||||
scroll: { flex: 1 },
|
||||
loader: { marginVertical: 30 },
|
||||
titleSection: { padding: 14 },
|
||||
title: { fontSize: 16, fontWeight: '600', color: '#212121', lineHeight: 22, marginBottom: 8 },
|
||||
statsRow: { flexDirection: 'row', gap: 16 },
|
||||
stat: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||
statText: { fontSize: 12, color: '#999' },
|
||||
upRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0' },
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#212121",
|
||||
lineHeight: 22,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statsRow: { flexDirection: "row", gap: 16 },
|
||||
stat: { flexDirection: "row", alignItems: "center", gap: 3 },
|
||||
statText: { fontSize: 12, color: "#999" },
|
||||
upRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 },
|
||||
upName: { flex: 1, fontSize: 14, color: '#212121', fontWeight: '500' },
|
||||
followBtn: { backgroundColor: '#00AEEC', paddingHorizontal: 14, paddingVertical: 5, borderRadius: 14 },
|
||||
followTxt: { color: '#fff', fontSize: 12, fontWeight: '600' },
|
||||
tabBar: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
|
||||
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12, position: 'relative' },
|
||||
tabLabel: { fontSize: 14, color: '#999' },
|
||||
tabActive: { color: '#00AEEC', fontWeight: '700' },
|
||||
tabUnderline: { position: 'absolute', bottom: 0, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 },
|
||||
upName: { flex: 1, fontSize: 14, color: "#212121", fontWeight: "500" },
|
||||
followBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 14,
|
||||
},
|
||||
followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: "#eee",
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
position: "relative",
|
||||
},
|
||||
tabLabel: { fontSize: 14, color: "#999" },
|
||||
tabActive: { color: "#00AEEC", fontWeight: "700" },
|
||||
tabUnderline: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
height: 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 1,
|
||||
},
|
||||
descBox: { padding: 16 },
|
||||
descText: { fontSize: 14, color: '#555', lineHeight: 22 },
|
||||
loadMore: { alignItems: 'center', padding: 16 },
|
||||
loadMoreTxt: { color: '#00AEEC', fontSize: 13 },
|
||||
emptyTxt: { textAlign: 'center', color: '#bbb', padding: 30 },
|
||||
descText: { fontSize: 14, color: "#555", lineHeight: 22 },
|
||||
loadMore: { alignItems: "center", padding: 16 },
|
||||
loadMoreTxt: { color: "#00AEEC", fontSize: 13 },
|
||||
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
|
||||
});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { File, Directory, Paths } from "expo-file-system";
|
||||
import {
|
||||
View, StyleSheet, TouchableOpacity, TouchableWithoutFeedback,
|
||||
Text, Modal, Image, PanResponder, useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import Video, { VideoRef } from 'react-native-video';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { PlayUrlResponse, VideoShotData, DanmakuItem } from '../services/types';
|
||||
import { buildDashMpdUri } from '../utils/dash';
|
||||
import { getHeatmap, getVideoShot } from '../services/bilibili';
|
||||
import DanmakuOverlay from './DanmakuOverlay';
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
Text,
|
||||
Modal,
|
||||
Image,
|
||||
PanResponder,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
PlayUrlResponse,
|
||||
VideoShotData,
|
||||
DanmakuItem,
|
||||
} from "../services/types";
|
||||
import { buildDashMpdUri } from "../utils/dash";
|
||||
import { getHeatmap, getVideoShot } from "../services/bilibili";
|
||||
import DanmakuOverlay from "./DanmakuOverlay";
|
||||
|
||||
const BAR_H = 3;
|
||||
// 进度球尺寸
|
||||
@@ -22,8 +34,9 @@ const SEGMENTS = 100;
|
||||
const HIDE_DELAY = 3000;
|
||||
|
||||
const HEADERS = {
|
||||
Referer: 'https://www.bilibili.com',
|
||||
'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',
|
||||
Referer: "https://www.bilibili.com",
|
||||
"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",
|
||||
};
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
@@ -49,63 +62,145 @@ function decodeFloats(base64: string): number[] {
|
||||
while (i < bytes.length) {
|
||||
const tag = bytes[i++];
|
||||
const wt = tag & 0x7;
|
||||
if (wt === 5) { floats.push(view.getFloat32(i, true)); i += 4; }
|
||||
else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); }
|
||||
else if (wt === 1) { i += 8; }
|
||||
else if (wt === 2) {
|
||||
let len = 0, shift = 0;
|
||||
do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true);
|
||||
if (wt === 5) {
|
||||
floats.push(view.getFloat32(i, true));
|
||||
i += 4;
|
||||
} else if (wt === 0) {
|
||||
while (i < bytes.length && bytes[i++] & 0x80);
|
||||
} else if (wt === 1) {
|
||||
i += 8;
|
||||
} else if (wt === 2) {
|
||||
let len = 0,
|
||||
shift = 0;
|
||||
do {
|
||||
const b = bytes[i++];
|
||||
len |= (b & 0x7f) << shift;
|
||||
shift += 7;
|
||||
if (!(b & 0x80)) break;
|
||||
} while (true);
|
||||
i += len;
|
||||
} else break;
|
||||
}
|
||||
return floats;
|
||||
}
|
||||
|
||||
function decodePvData(base64: string): number[] {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const view = new DataView(bytes.buffer);
|
||||
function decodePvBuffer(buffer: ArrayBuffer): number[] {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
const timestamps: number[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < bytes.length) {
|
||||
const tag = bytes[i++];
|
||||
const wt = tag & 0x7;
|
||||
if (wt === 5) { timestamps.push(view.getFloat32(i, true)); i += 4; }
|
||||
else if (wt === 2) {
|
||||
// Packed floats — read length prefix then extract each float32
|
||||
let len = 0, shift = 0;
|
||||
do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true);
|
||||
const wireType = tag & 0x07;
|
||||
const fieldNum = tag >> 3;
|
||||
|
||||
// 我们主要关心 repeated float32,通常 field 1 或直接数据
|
||||
if (wireType === 5) {
|
||||
// fixed32 / float32
|
||||
if (i + 4 > bytes.length) break;
|
||||
timestamps.push(view.getFloat32(i, true)); // little-endian
|
||||
i += 4;
|
||||
} else if (wireType === 2) {
|
||||
// length-delimited → 进入子消息或 packed repeated
|
||||
let len = 0;
|
||||
let shift = 0;
|
||||
while (true) {
|
||||
if (i >= bytes.length) break;
|
||||
const b = bytes[i++];
|
||||
len |= (b & 0x7f) << shift;
|
||||
shift += 7;
|
||||
if (!(b & 0x80)) break;
|
||||
}
|
||||
const end = i + len;
|
||||
while (i < end) { timestamps.push(view.getFloat32(i, true)); i += 4; }
|
||||
// packed repeated float32 最常见情况:直接连续 float32
|
||||
while (i + 4 <= end) {
|
||||
timestamps.push(view.getFloat32(i, true));
|
||||
i += 4;
|
||||
}
|
||||
// 如果不是 packed,也跳过
|
||||
} else if (wireType === 0) {
|
||||
// varint
|
||||
while (i < bytes.length && bytes[i++] & 0x80);
|
||||
} else if (wireType === 1) {
|
||||
// fixed64
|
||||
i += 8;
|
||||
} else {
|
||||
break; // 未知类型,停止
|
||||
}
|
||||
else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); }
|
||||
else if (wt === 1) { i += 8; }
|
||||
else break;
|
||||
}
|
||||
return timestamps;
|
||||
|
||||
// 过滤掉明显异常值(比如负数或极大值)
|
||||
return timestamps.filter((t) => t >= 0 && t < 86400); // 视频不会超过24小时
|
||||
}
|
||||
|
||||
async function loadPvData(url: string) {
|
||||
const realUrl = url.startsWith("//") ? `https:${url}` : url;
|
||||
|
||||
try {
|
||||
// 选择缓存目录下的一个子目录(避免污染根缓存)
|
||||
const cacheDir = new Directory(Paths.cache, "bili_pvdata");
|
||||
|
||||
// 如果目录不存在,创建(intermediates: true 自动创建父目录)
|
||||
if (!cacheDir.exists) {
|
||||
await cacheDir.create({ intermediates: true });
|
||||
}
|
||||
|
||||
// 下载文件到这个目录(会自动用远程文件名,或你可以指定 File)
|
||||
// 这里用 Directory 作为 destination,SDK 会从 URL 或 header 推导文件名
|
||||
const downloadedFile: File = await File.downloadFileAsync(
|
||||
realUrl,
|
||||
cacheDir,
|
||||
{
|
||||
headers: HEADERS,
|
||||
idempotent: true, // 如果文件已存在,覆盖(避免重复下载失败)
|
||||
},
|
||||
);
|
||||
console.log("Downloaded to:", downloadedFile.uri);
|
||||
// 读取为 base64(如果你原来的 decodeFloats/decodePvBuffer 用 base64)
|
||||
// const base64 = await downloadedFile.base64();
|
||||
// 更好:直接读 binary 为 Uint8Array,然后转 ArrayBuffer
|
||||
const bytes: Uint8Array = await downloadedFile.bytes();
|
||||
const nums = new Uint16Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength / 2,
|
||||
);
|
||||
|
||||
return nums;
|
||||
} catch (error) {
|
||||
console.error("loadPvData failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function findFrameIdx(timestamps: number[], seekTime: number): number {
|
||||
if (!timestamps.length) return 0;
|
||||
let lo = 0, hi = timestamps.length - 1;
|
||||
let lo = 0,
|
||||
hi = timestamps.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (timestamps[mid] <= seekTime) lo = mid; else hi = mid - 1;
|
||||
if (timestamps[mid] <= seekTime) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
function downsample(data: number[], n: number): number[] {
|
||||
if (!data.length) return Array(n).fill(0);
|
||||
const out = Array.from({ length: n }, (_, i) => data[Math.floor((i / n) * data.length)]);
|
||||
const out = Array.from(
|
||||
{ length: n },
|
||||
(_, i) => data[Math.floor((i / n) * data.length)],
|
||||
);
|
||||
const max = Math.max(...out);
|
||||
return max ? out.map(v => v / max) : out;
|
||||
return max ? out.map((v) => v / max) : out;
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
return `${m}:${Math.floor(s % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -125,8 +220,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export function NativeVideoPlayer({
|
||||
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
|
||||
bvid, cid, danmakus, isFullscreen, onTimeUpdate, initialTime,
|
||||
playData,
|
||||
qualities,
|
||||
currentQn,
|
||||
onQualityChange,
|
||||
onFullscreen,
|
||||
onMiniPlayer,
|
||||
style,
|
||||
bvid,
|
||||
cid,
|
||||
danmakus,
|
||||
isFullscreen,
|
||||
onTimeUpdate,
|
||||
initialTime,
|
||||
}: Props) {
|
||||
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
|
||||
const VIDEO_H = SCREEN_W * 0.5625;
|
||||
@@ -157,11 +263,16 @@ export function NativeVideoPlayer({
|
||||
const [showDanmaku, setShowDanmaku] = useState(true);
|
||||
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD');
|
||||
const currentDesc =
|
||||
qualities.find((q) => q.qn === currentQn)?.desc ??
|
||||
String(currentQn || "HD");
|
||||
|
||||
// URL resolution
|
||||
useEffect(() => {
|
||||
if (!playData) { setResolvedUrl(undefined); return; }
|
||||
if (!playData) {
|
||||
setResolvedUrl(undefined);
|
||||
return;
|
||||
}
|
||||
if (isDash) {
|
||||
buildDashMpdUri(playData, currentQn)
|
||||
.then(setResolvedUrl)
|
||||
@@ -175,24 +286,41 @@ export function NativeVideoPlayer({
|
||||
useEffect(() => {
|
||||
if (!bvid || !cid) return;
|
||||
let cancelled = false;
|
||||
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatmap, shotData]) => {
|
||||
if (cancelled) return;
|
||||
if (heatmap?.pb_data) {
|
||||
try { setHeatSegments(downsample(decodeFloats(heatmap.pb_data), SEGMENTS)); }
|
||||
catch { setHeatSegments([]); }
|
||||
}
|
||||
if (shotData?.image?.length) {
|
||||
setShots(shotData);
|
||||
if (shotData.pvdata) {
|
||||
try { setShotTimestamps(decodePvData(shotData.pvdata)); }
|
||||
catch { setShotTimestamps([]); }
|
||||
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(
|
||||
([heatmap, shotData]) => {
|
||||
if (cancelled) return;
|
||||
if (heatmap?.pb_data) {
|
||||
try {
|
||||
setHeatSegments(
|
||||
downsample(decodeFloats(heatmap.pb_data), SEGMENTS),
|
||||
);
|
||||
} catch {
|
||||
setHeatSegments([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
if (shotData?.image?.length) {
|
||||
setShots(shotData);
|
||||
console.log(shotData.pvdata, "pvdata");
|
||||
if (shotData.pvdata) {
|
||||
try {
|
||||
loadPvData(shotData.pvdata).then((r) => {
|
||||
setShotTimestamps(r);
|
||||
});
|
||||
} catch {
|
||||
setShotTimestamps([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bvid, cid]);
|
||||
|
||||
useEffect(() => { durationRef.current = duration; }, [duration]);
|
||||
useEffect(() => {
|
||||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
@@ -207,8 +335,11 @@ export function NativeVideoPlayer({
|
||||
}, [resetHideTimer]);
|
||||
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls(prev => {
|
||||
if (!prev) { resetHideTimer(); return true; }
|
||||
setShowControls((prev) => {
|
||||
if (!prev) {
|
||||
resetHideTimer();
|
||||
return true;
|
||||
}
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
return false;
|
||||
});
|
||||
@@ -217,7 +348,9 @@ export function NativeVideoPlayer({
|
||||
// Start hide timer on mount
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measureTrack = useCallback(() => {
|
||||
@@ -244,7 +377,11 @@ export function NativeVideoPlayer({
|
||||
setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current));
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
const ratio = clamp((gs.moveX - barOffsetX.current) / barWidthRef.current, 0, 1);
|
||||
const ratio = clamp(
|
||||
(gs.moveX - barOffsetX.current) / barWidthRef.current,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const t = ratio * durationRef.current;
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
@@ -252,32 +389,43 @@ export function NativeVideoPlayer({
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setShowControls(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
},
|
||||
})
|
||||
}),
|
||||
).current;
|
||||
|
||||
const touchRatio = touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
||||
const touchRatio =
|
||||
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
||||
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||
|
||||
const THUMB_DISPLAY_W = 120; // scaled display width
|
||||
|
||||
const renderThumbnail = () => {
|
||||
if (touchRatio === null || !shots || !isSeeking) return null;
|
||||
const { img_x_size: TW, img_y_size: TH, img_x_len, img_y_len, image } = shots;
|
||||
const {
|
||||
img_x_size: TW,
|
||||
img_y_size: TH,
|
||||
img_x_len,
|
||||
img_y_len,
|
||||
image,
|
||||
} = shots;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const totalFrames = framesPerSheet * image.length;
|
||||
|
||||
// Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation
|
||||
const seekTime = touchRatio * duration;
|
||||
const frameIdx = shotTimestamps.length > 0
|
||||
? findFrameIdx(shotTimestamps, seekTime)
|
||||
: Math.floor(touchRatio * (totalFrames - 1));
|
||||
const frameIdx =
|
||||
shotTimestamps.length > 0
|
||||
? findFrameIdx(shotTimestamps, seekTime)
|
||||
: Math.floor(touchRatio * (totalFrames - 1));
|
||||
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
@@ -293,14 +441,21 @@ export function NativeVideoPlayer({
|
||||
const absLeft = clamp(trackLeft + (touchX ?? 0) - DW / 2, 0, SCREEN_W - DW);
|
||||
|
||||
// Protocol-relative URLs from B站 API need explicit https:
|
||||
const sheetUrl = image[sheetIdx].startsWith('//') ? `https:${image[sheetIdx]}` : image[sheetIdx];
|
||||
const sheetUrl = image[sheetIdx].startsWith("//")
|
||||
? `https:${image[sheetIdx]}`
|
||||
: image[sheetIdx];
|
||||
return (
|
||||
<View style={[styles.thumbPreview, { left: absLeft, width: DW }]} pointerEvents="none">
|
||||
<View style={{ width: DW, height: DH, overflow: 'hidden', borderRadius: 4 }}>
|
||||
<View
|
||||
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View
|
||||
style={{ width: DW, height: DH, overflow: "hidden", borderRadius: 4 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: sheetUrl, headers: HEADERS }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
width: TW * img_x_len * scale,
|
||||
height: TH * img_y_len * scale,
|
||||
left: -col * DW,
|
||||
@@ -314,14 +469,22 @@ export function NativeVideoPlayer({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }, style]}>
|
||||
<View
|
||||
style={[
|
||||
isFullscreen
|
||||
? styles.fsContainer
|
||||
: [styles.container, { width: SCREEN_W, height: VIDEO_H }],
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{resolvedUrl ? (
|
||||
<Video
|
||||
key={resolvedUrl}
|
||||
ref={videoRef}
|
||||
source={isDash
|
||||
? { uri: resolvedUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
source={
|
||||
isDash
|
||||
? { uri: resolvedUrl, type: "mpd", headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
@@ -361,27 +524,41 @@ export function NativeVideoPlayer({
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.55)', 'transparent']}
|
||||
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{onMiniPlayer && (
|
||||
<TouchableOpacity onPress={onMiniPlayer} style={styles.topBtn}>
|
||||
<Ionicons name="tablet-portrait-outline" size={20} color="#fff" />
|
||||
<Ionicons
|
||||
name="tablet-portrait-outline"
|
||||
size={20}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
|
||||
{/* Center play/pause */}
|
||||
<TouchableOpacity style={styles.centerBtn} onPress={() => { setPaused(p => !p); showAndReset(); }}>
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={28}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
||||
style={styles.bottomBar}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
@@ -393,38 +570,86 @@ export function NativeVideoPlayer({
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
{heatSegments.length > 0
|
||||
? heatSegments.map((v, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.seg, { backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` as any }]}
|
||||
/>
|
||||
))
|
||||
: <View style={[styles.seg, { flex: 1, backgroundColor: '#00AEEC' }]} />
|
||||
}
|
||||
<View style={[styles.playedOverlay, { width: `${progressRatio * 100}%` as any }]} />
|
||||
{heatSegments.length > 0 ? (
|
||||
heatSegments.map((v, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.seg,
|
||||
{
|
||||
backgroundColor: heatColor(v),
|
||||
width: `${100 / SEGMENTS}%` as any,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.seg,
|
||||
{ flex: 1, backgroundColor: "#00AEEC" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.playedOverlay,
|
||||
{ width: `${progressRatio * 100}%` as any },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View style={[styles.ball, styles.ballActive, { left: touchX - BALL_ACTIVE / 2 }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
styles.ballActive,
|
||||
{ left: touchX - BALL_ACTIVE / 2 },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.ball, { left: progressRatio * barWidthRef.current - BALL / 2 }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.ball,
|
||||
{ left: progressRatio * barWidthRef.current - BALL / 2 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity onPress={() => { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setPaused((p) => !p);
|
||||
showAndReset();
|
||||
}}
|
||||
style={styles.ctrlBtn}
|
||||
>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowQuality(true)}
|
||||
>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</TouchableOpacity>
|
||||
{isFullscreen && (
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowDanmaku(v => !v)}>
|
||||
<Ionicons name={showDanmaku ? 'chatbubbles' : 'chatbubbles-outline'} size={16} color="#fff" />
|
||||
<TouchableOpacity
|
||||
style={styles.ctrlBtn}
|
||||
onPress={() => setShowDanmaku((v) => !v)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
@@ -440,19 +665,33 @@ export function NativeVideoPlayer({
|
||||
|
||||
{/* Quality modal */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setShowQuality(false)}
|
||||
>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map(q => (
|
||||
{qualities.map((q) => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }}
|
||||
onPress={() => {
|
||||
setShowQuality(false);
|
||||
onQualityChange(q.qn);
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.qualityItemText,
|
||||
q.qn === currentQn && styles.qualityItemActive,
|
||||
]}
|
||||
>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
|
||||
{q.qn === currentQn && (
|
||||
<Ionicons name="checkmark" size={16} color="#00AEEC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -463,66 +702,127 @@ export function NativeVideoPlayer({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: '#000' },
|
||||
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
|
||||
container: { backgroundColor: "#000" },
|
||||
fsContainer: { flex: 1, backgroundColor: "#000" },
|
||||
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
|
||||
topBar: {
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: 56,
|
||||
paddingHorizontal: 12, paddingTop: 10,
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 56,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 10,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
topBtn: { padding: 6 },
|
||||
centerBtn: {
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: [{ translateX: -28 }, { translateY: -28 }],
|
||||
},
|
||||
centerBtnBg: {
|
||||
width: 56, height: 56, borderRadius: 28,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: "rgba(0,0,0,0.45)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
bottomBar: {
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
paddingBottom: 8, paddingTop: 32,
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 32,
|
||||
},
|
||||
thumbPreview: { position: 'absolute', bottom: 64, alignItems: 'center' },
|
||||
thumbPreview: { position: "absolute", bottom: 64, alignItems: "center" },
|
||||
thumbTime: {
|
||||
color: '#fff', fontSize: 11, fontWeight: '600', marginTop: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.7)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
marginTop: 2,
|
||||
textShadowColor: "rgba(0,0,0,0.7)",
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
trackWrapper: {
|
||||
marginHorizontal: 8,
|
||||
height: BAR_H + BALL_ACTIVE,
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
track: {
|
||||
height: BAR_H, flexDirection: 'row',
|
||||
borderRadius: 2, overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
height: BAR_H,
|
||||
flexDirection: "row",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
},
|
||||
seg: { height: BAR_H },
|
||||
playedOverlay: {
|
||||
position: 'absolute', top: 0, left: 0, height: BAR_H,
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: BAR_H,
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
},
|
||||
ball: {
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2,
|
||||
width: BALL, height: BALL, borderRadius: BALL / 2,
|
||||
backgroundColor: '#fff', elevation: 3,
|
||||
width: BALL,
|
||||
height: BALL,
|
||||
borderRadius: BALL / 2,
|
||||
backgroundColor: "#fff",
|
||||
elevation: 3,
|
||||
},
|
||||
ballActive: {
|
||||
width: BALL_ACTIVE, height: BALL_ACTIVE, borderRadius: BALL_ACTIVE / 2,
|
||||
backgroundColor: '#00AEEC', top: 0,
|
||||
width: BALL_ACTIVE,
|
||||
height: BALL_ACTIVE,
|
||||
borderRadius: BALL_ACTIVE / 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
top: 0,
|
||||
},
|
||||
ctrlRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
ctrlRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, marginTop: 4 },
|
||||
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
|
||||
timeText: { color: '#fff', fontSize: 11, marginHorizontal: 2 },
|
||||
qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' },
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' },
|
||||
qualityList: { backgroundColor: '#fff', borderRadius: 12, paddingVertical: 8, paddingHorizontal: 16, minWidth: 180 },
|
||||
qualityTitle: { fontSize: 15, fontWeight: '700', color: '#212121', paddingVertical: 10, textAlign: 'center' },
|
||||
qualityItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#eee' },
|
||||
qualityItemText: { fontSize: 14, color: '#333' },
|
||||
qualityItemActive: { color: '#00AEEC', fontWeight: '700' },
|
||||
timeText: { color: "#fff", fontSize: 11, marginHorizontal: 2 },
|
||||
qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
qualityList: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
minWidth: 180,
|
||||
},
|
||||
qualityTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: "#212121",
|
||||
paddingVertical: 10,
|
||||
textAlign: "center",
|
||||
},
|
||||
qualityItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 12,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#eee",
|
||||
},
|
||||
qualityItemText: { fontSize: 14, color: "#333" },
|
||||
qualityItemActive: { color: "#00AEEC", fontWeight: "700" },
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatCount, formatDuration } from '../utils/format';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_WIDTH = (width - 24) / 2;
|
||||
const CARD_WIDTH = (width - 14) / 2;
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
@@ -39,7 +39,7 @@ export function VideoCard({ item, onPress }: Props) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { width: CARD_WIDTH, marginBottom: 12, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
|
||||
card: { width: CARD_WIDTH, marginBottom: 6, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' },
|
||||
thumbContainer: { position: 'relative' },
|
||||
thumb: { width: CARD_WIDTH, height: CARD_WIDTH * 0.5625 },
|
||||
durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1 },
|
||||
|
||||
@@ -22,14 +22,14 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
// When ScreenOrientation is unavailable (Expo Go), simulate landscape via transform
|
||||
// In Expo Go ScreenOrientation is unavailable; simulate landscape via CSS transform
|
||||
const needsRotation = !ScreenOrientation && fullscreen;
|
||||
const lastTimeRef = useRef(0);
|
||||
|
||||
const handleEnterFullscreen = async () => {
|
||||
setFullscreen(true);
|
||||
if (Platform.OS !== 'web')
|
||||
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
const handleExitFullscreen = async () => {
|
||||
@@ -85,7 +85,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
|
||||
<Modal visible={fullscreen} animationType="none" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={needsRotation
|
||||
@@ -104,7 +104,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, o
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
41
dev-proxy.js
41
dev-proxy.js
@@ -1,4 +1,5 @@
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
@@ -29,8 +30,9 @@ function makeProxy(targetHost) {
|
||||
'Referer': 'https://www.bilibili.com',
|
||||
'Origin': 'https://www.bilibili.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,6 +58,41 @@ function makeProxy(targetHost) {
|
||||
app.use('/bilibili-api', makeProxy('api.bilibili.com'));
|
||||
app.use('/bilibili-passport', makeProxy('passport.bilibili.com'));
|
||||
|
||||
// Dedicated comment proxy: buffer response and decompress by magic bytes (not Content-Encoding header)
|
||||
app.use('/bilibili-comment', (req, res) => {
|
||||
const options = {
|
||||
hostname: 'comment.bilibili.com',
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
'Referer': 'https://www.bilibili.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
};
|
||||
const proxy = https.request(options, (proxyRes) => {
|
||||
res.setHeader('Content-Type', proxyRes.headers['content-type'] || 'text/xml; charset=utf-8');
|
||||
const chunks = [];
|
||||
proxyRes.on('data', chunk => chunks.push(chunk));
|
||||
proxyRes.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf[0] === 0x1f && buf[1] === 0x8b) {
|
||||
// actual gzip data — decompress regardless of Content-Encoding header
|
||||
zlib.gunzip(buf, (err, result) => {
|
||||
if (err) res.status(502).end('gunzip error: ' + err.message);
|
||||
else res.end(result);
|
||||
});
|
||||
} else {
|
||||
res.end(buf);
|
||||
}
|
||||
});
|
||||
proxyRes.on('error', (err) => res.status(502).json({ error: err.message }));
|
||||
});
|
||||
proxy.on('error', (err) => res.status(502).json({ error: err.message }));
|
||||
req.pipe(proxy);
|
||||
});
|
||||
|
||||
// Image CDN proxy — strips the host segment and forwards to the real CDN with Referer
|
||||
app.use('/bilibili-img', (req, res) => {
|
||||
const parts = req.url.split('/').filter(Boolean);
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getRecommendFeed } from '../services/bilibili';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export function useVideoList() {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [freshIdx, setFreshIdx] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Use refs to avoid stale closures — load() has stable identity
|
||||
const loadingRef = useRef(false);
|
||||
const freshIdxRef = useRef(0);
|
||||
|
||||
const load = useCallback(async (reset = false) => {
|
||||
if (loading) return;
|
||||
const idx = reset ? 0 : freshIdx;
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
const idx = reset ? 0 : freshIdxRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRecommendFeed(idx);
|
||||
setVideos(prev => reset ? data : [...prev, ...data]);
|
||||
setFreshIdx(idx + 1);
|
||||
freshIdxRef.current = idx + 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to load videos', e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [loading, freshIdx]);
|
||||
}, []); // stable — no stale closure risk
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
|
||||
148
package-lock.json
generated
148
package-lock.json
generated
@@ -20,6 +20,8 @@
|
||||
"expo-screen-orientation": "~55.0.8",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "~55.0.9",
|
||||
"fast-xml-parser": "^5.5.1",
|
||||
"pako": "^2.1.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
@@ -28,9 +30,11 @@
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.2",
|
||||
"express": "^4.22.1",
|
||||
"typescript": "~5.9.2"
|
||||
@@ -64,7 +68,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1390,7 +1393,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -1514,6 +1516,28 @@
|
||||
"xml2js": "0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins/node_modules/xml2js": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
|
||||
"integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins/node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-types": {
|
||||
"version": "55.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz",
|
||||
@@ -1647,7 +1671,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-55.0.7.tgz",
|
||||
"integrity": "sha512-m7V1k2vlMp4NOj3fopjOg4zl/ANXyTRF3HMTMep2GZAKsPiDzgOQ41nm8CaU50/HlDIGXlCObss07gOn20UpHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@expo/dom-webview": "^55.0.3",
|
||||
"anser": "^1.4.9",
|
||||
@@ -2804,7 +2827,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz",
|
||||
"integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.16.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
@@ -2952,13 +2974,19 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3545,7 +3573,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4266,7 +4293,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-55.0.5.tgz",
|
||||
"integrity": "sha512-toVYbRU0gH50QSlIyrAswXD87RKi2pcJcHZpBDuqU3mIQZzJkTcWgRLWN/2R/wnd3kuJTtW5xlr5ndVG6xEWxQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "55.0.15",
|
||||
@@ -4338,7 +4364,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz",
|
||||
"integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@expo/config": "~55.0.8",
|
||||
"@expo/env": "~2.1.1"
|
||||
@@ -4414,7 +4439,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.4.tgz",
|
||||
"integrity": "sha512-ZKeGTFffPygvY5dM/9ATM2p7QDkhsaHopH7wFAWgP2lKzqUMS9B/RxCvw5CaObr9Ro7x9YptyeRKX2HmgmMfrg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fontfaceobserver": "^2.1.0"
|
||||
},
|
||||
@@ -4684,7 +4708,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz",
|
||||
"integrity": "sha512-xI72FTm469FfuuBL2R5aNtthgH+GR7ygOpsx/KcPS0K8AZaZd7VjtEExbzn9/qyyYkWW3T+3dAmCDKOMX8gdmQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.16.0"
|
||||
}
|
||||
@@ -5039,6 +5062,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/xml2js": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
|
||||
"integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
@@ -5173,6 +5218,41 @@
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.0.tgz",
|
||||
"integrity": "sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.1.tgz",
|
||||
"integrity": "sha512-JTpMz8P5mDoNYzXTmTT/xzWjFiCWi0U+UQTJtrFH9muXsr2RqtXZPbnCW5h2mKsOd4u3XcPWCvDSrnaBPlUcMQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.0",
|
||||
"path-expression-matcher": "^1.1.2",
|
||||
"strnum": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fb-dotslash": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz",
|
||||
@@ -7293,6 +7373,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-png": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz",
|
||||
@@ -7323,6 +7409,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.2.tgz",
|
||||
"integrity": "sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -7622,7 +7723,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7642,7 +7742,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7679,7 +7778,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.2.tgz",
|
||||
"integrity": "sha512-ZDma3SLkRN2U2dg0/EZqxNBAx4of/oTnPjXAQi299VLq2gdnbZowGy9hzqv+O7sTA62g+lM1v+2FM5DUnJ/6hg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/create-cache-key-function": "^29.7.0",
|
||||
"@react-native/assets-registry": "0.83.2",
|
||||
@@ -7748,7 +7846,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
@@ -7759,7 +7856,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.23.0.tgz",
|
||||
"integrity": "sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"warn-once": "^0.1.0"
|
||||
@@ -7784,7 +7880,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
@@ -7817,7 +7912,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||
"integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"invariant": "2.2.4"
|
||||
@@ -7913,7 +8007,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8660,6 +8753,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/structured-headers": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz",
|
||||
@@ -8901,7 +9006,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9275,9 +9379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
|
||||
"integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==",
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start": "expo start --port 8082",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
@@ -22,6 +22,8 @@
|
||||
"expo-screen-orientation": "~55.0.8",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "~55.0.9",
|
||||
"fast-xml-parser": "^5.5.1",
|
||||
"pako": "^2.1.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
@@ -30,9 +32,11 @@
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.2",
|
||||
"express": "^4.22.1",
|
||||
"typescript": "~5.9.2"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
import pako from 'pako';
|
||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, HeatmapResponse, DanmakuItem } from './types';
|
||||
import { signWbi } from '../utils/wbi';
|
||||
import { parseDanmakuXml } from '../utils/danmaku';
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
const BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com';
|
||||
const BASE = isWeb ? 'http://localhost:3001/bilibili-api' : 'https://api.bilibili.com';
|
||||
const PASSPORT = isWeb ? 'http://localhost:3001/bilibili-passport' : 'https://passport.bilibili.com';
|
||||
const COMMENT_BASE = isWeb
|
||||
? 'http://localhost:3001/bilibili-comment'
|
||||
@@ -32,13 +33,13 @@ const api = axios.create({
|
||||
baseURL: BASE,
|
||||
timeout: 10000,
|
||||
headers: isWeb ? {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
} : {
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://www.bilibili.com',
|
||||
'Origin': 'https://www.bilibili.com',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://www.bilibili.com',
|
||||
'Origin': 'https://www.bilibili.com',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
});
|
||||
@@ -50,7 +51,7 @@ api.interceptors.request.use(async (config) => {
|
||||
]);
|
||||
if (isWeb) {
|
||||
// Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy
|
||||
if (buvid3) config.headers['X-Buvid3'] = buvid3;
|
||||
if (buvid3) config.headers['X-Buvid3'] = buvid3;
|
||||
if (sessdata) config.headers['X-Sessdata'] = sessdata;
|
||||
} else {
|
||||
const cookies: string[] = [`buvid3=${buvid3}`];
|
||||
@@ -75,7 +76,7 @@ async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> {
|
||||
export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
|
||||
const { imgKey, subKey } = await getWbiKeys();
|
||||
const signed = signWbi(
|
||||
{ fresh_type: 3, fresh_idx: freshIdx, fresh_idx_1h: freshIdx, ps: 12, feed_version: 'V8' },
|
||||
{ fresh_type: 3, fresh_idx: freshIdx, fresh_idx_1h: freshIdx, ps: 21, feed_version: 'V8' },
|
||||
imgKey,
|
||||
subKey,
|
||||
);
|
||||
@@ -87,8 +88,7 @@ export async function getRecommendFeed(freshIdx = 0): Promise<VideoItem[]> {
|
||||
aid: item.id ?? item.aid,
|
||||
pic: item.pic ?? item.cover,
|
||||
owner: item.owner ?? { mid: 0, name: item.owner_info?.name ?? '', face: item.owner_info?.face ?? '' },
|
||||
}))
|
||||
.filter((item: any) => item.bvid && (item.pic || item.cover) && item.duration > 0) as VideoItem[];
|
||||
})) as VideoItem[];
|
||||
}
|
||||
|
||||
export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
|
||||
@@ -172,12 +172,44 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
|
||||
return { code, cookie };
|
||||
}
|
||||
|
||||
|
||||
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
|
||||
try {
|
||||
if (isWeb) {
|
||||
// web 走代理,代理已解压,直接拿文本
|
||||
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
|
||||
headers: {},
|
||||
responseType: 'text',
|
||||
});
|
||||
return parseDanmakuXml(res.data);
|
||||
}
|
||||
|
||||
// Native 策略 1:responseType: 'text',依赖 OkHttp 自动解压
|
||||
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
|
||||
headers: isWeb ? {} : { Referer: 'https://www.bilibili.com', 'User-Agent': UA },
|
||||
headers: { Referer: 'https://www.bilibili.com', 'User-Agent': UA },
|
||||
responseType: 'text',
|
||||
});
|
||||
return parseDanmakuXml(res.data as string);
|
||||
} catch { return []; }
|
||||
|
||||
if (typeof res.data === 'string' && res.data.includes('<d ')) {
|
||||
return parseDanmakuXml(res.data);
|
||||
}
|
||||
|
||||
// 策略 2 回退:arraybuffer + pako 手动解压
|
||||
const res2 = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
|
||||
headers: { Referer: 'https://www.bilibili.com', 'User-Agent': UA },
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const bytes = new Uint8Array(res2.data as ArrayBuffer);
|
||||
let xmlText: string;
|
||||
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||||
xmlText = pako.inflate(bytes, { to: 'string' });
|
||||
} else {
|
||||
xmlText = new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
return parseDanmakuXml(xmlText);
|
||||
} catch (e) {
|
||||
console.warn('getDanmaku failed:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export function parseDanmakuXml(xml: string): DanmakuItem[] {
|
||||
return items.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
|
||||
export function danmakuColorToCss(color: number): string {
|
||||
return '#' + (color >>> 0 & 0xFFFFFF).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
|
||||
@@ -13,26 +13,26 @@ export interface BigRow {
|
||||
|
||||
export type ListRow = NormalRow | BigRow;
|
||||
|
||||
const PAGE = 21; // matches API page size
|
||||
|
||||
/**
|
||||
* Transform a flat VideoItem array into display rows.
|
||||
* The last item always becomes a full-width BigRow.
|
||||
* All preceding items are grouped into NormalRow pairs.
|
||||
* Videos are chunked by page size (20). The last item of each chunk
|
||||
* becomes a full-width BigRow so BigVideoCards stay at stable positions
|
||||
* even as more pages are loaded.
|
||||
*/
|
||||
|
||||
export function toListRows(videos: VideoItem[]): ListRow[] {
|
||||
if (videos.length === 0) return [];
|
||||
if (videos.length === 1) return [{ type: 'big', item: videos[0] }];
|
||||
|
||||
const rows: ListRow[] = [];
|
||||
const body = videos.slice(0, videos.length - 1);
|
||||
|
||||
for (let i = 0; i < body.length; i += 2) {
|
||||
rows.push({
|
||||
type: 'pair',
|
||||
left: body[i],
|
||||
right: body[i + 1] ?? null,
|
||||
});
|
||||
for (let start = 0; start < videos.length; start += PAGE) {
|
||||
const chunk = videos.slice(start, start + PAGE);
|
||||
const body = chunk.slice(0, chunk.length - 1);
|
||||
for (let i = 0; i < body.length; i += 2) {
|
||||
rows.push({ type: 'pair', left: body[i], right: body[i + 1] ?? null });
|
||||
}
|
||||
rows.push({ type: 'big', item: chunk[chunk.length - 1] });
|
||||
}
|
||||
|
||||
rows.push({ type: 'big', item: videos[videos.length - 1] });
|
||||
return rows;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user