This commit is contained in:
Developer
2026-03-11 20:53:18 +08:00
parent 4e8beae209
commit 9347c8752d
12 changed files with 836 additions and 261 deletions

View File

@@ -118,6 +118,7 @@ export default function HomeScreen() {
}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
extraData={visibleBigKey}
viewabilityConfig={VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChangedRef}
ListFooterComponent={

View File

@@ -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 },
});

View File

@@ -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 作为 destinationSDK 会从 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" },
});

View File

@@ -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 },

View File

@@ -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>

View File

@@ -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);

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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 策略 1responseType: '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 [];
}
}

View File

@@ -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');
}

View File

@@ -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;
}