mirror of
https://github.com/tiajinsha/JKVideo.git
synced 2026-05-06 22:02:23 +08:00
feat: unlock 1080P+ on Android via DASH streaming
- getPlayUrl uses fnval=16 (DASH) on Android, keeping fnval=0/html5 for iOS/web - New utils/dash.ts builds a valid DASH MPD from Bilibili's segmentBase ranges and returns it as a data: URI for ExoPlayer consumption - NativeVideoPlayer selects DASH path (type='mpd') or durl fallback automatically - Extend PlayUrlResponse types with DashVideoItem/DashAudioItem/DashSegmentBase
This commit is contained in:
3
app.json
3
app.json
@@ -36,7 +36,8 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router"
|
||||
"expo-router",
|
||||
"react-native-video"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View, StyleSheet, Dimensions, TouchableOpacity,
|
||||
Text, Modal, FlatList,
|
||||
Text, Modal,
|
||||
} from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import Video, { VideoRef } from 'react-native-video';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { buildMpd } from '../utils/buildMpd';
|
||||
import type { PlayUrlResponse } from '../services/types';
|
||||
import { buildDashDataUri } from '../utils/dash';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
|
||||
const BILIBILI_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',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
@@ -19,76 +24,46 @@ interface Props {
|
||||
onFullscreen: () => void;
|
||||
onMiniPlayer?: () => void;
|
||||
style?: object;
|
||||
}
|
||||
|
||||
function buildMp4Html(url: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>* { margin:0; padding:0; box-sizing:border-box; background:#000; } video { width:100vw; height:100vh; object-fit:contain; display:block; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="v" controls autoplay playsinline webkit-playsinline></video>
|
||||
<script>document.getElementById('v').src = ${JSON.stringify(url)};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildDashHtml(mpdStr: string): string {
|
||||
const mpdBase64 = `data:application/dash+xml;base64,${btoa(unescape(encodeURIComponent(mpdStr)))}`;
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>* { margin:0; padding:0; box-sizing:border-box; background:#000; } video { width:100vw; height:100vh; object-fit:contain; display:block; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="v" autoplay controls playsinline webkit-playsinline></video>
|
||||
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
|
||||
<script>
|
||||
var player = dashjs.MediaPlayer().create();
|
||||
player.initialize(document.getElementById('v'), ${JSON.stringify(mpdBase64)}, true);
|
||||
player.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function getHtml(playData: PlayUrlResponse | null): string {
|
||||
if (!playData) return '<html><body style="background:#000"></body></html>';
|
||||
if (playData.dash) {
|
||||
const v = playData.dash.video[0];
|
||||
const a = playData.dash.audio[0];
|
||||
if (v && a) {
|
||||
const mpd = buildMpd(v.baseUrl, v.codecs, v.bandwidth, a.baseUrl, a.codecs, a.bandwidth);
|
||||
return buildDashHtml(mpd);
|
||||
}
|
||||
}
|
||||
const url = playData.durl?.[0]?.url;
|
||||
if (url) return buildMp4Html(url);
|
||||
return '<html><body style="background:#000"></body></html>';
|
||||
onProgress?: (currentTime: number, duration: number) => void;
|
||||
seekTo?: { t: number; v: number };
|
||||
}
|
||||
|
||||
export function NativeVideoPlayer({
|
||||
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
|
||||
onProgress, seekTo,
|
||||
}: Props) {
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? (currentQn ? String(currentQn) : 'HD');
|
||||
const html = getHtml(playData);
|
||||
const isDash = !!playData?.dash;
|
||||
const url = isDash
|
||||
? buildDashDataUri(playData!, currentQn)
|
||||
: playData?.durl?.[0]?.url;
|
||||
|
||||
useEffect(() => {
|
||||
if (seekTo !== undefined) videoRef.current?.seek(seekTo.t);
|
||||
}, [seekTo]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<WebView
|
||||
key={html}
|
||||
source={{ html }}
|
||||
style={styles.webview}
|
||||
allowsInlineMediaPlayback
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
javaScriptEnabled
|
||||
originWhitelist={['*']}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
{url ? (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={isDash
|
||||
? { uri: url, type: 'mpd', headers: BILIBILI_HEADERS }
|
||||
: { uri: url, headers: BILIBILI_HEADERS }
|
||||
}
|
||||
style={styles.video}
|
||||
resizeMode="contain"
|
||||
controls
|
||||
paused={false}
|
||||
onProgress={({ currentTime, seekableDuration }) =>
|
||||
onProgress?.(currentTime, seekableDuration)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
@@ -112,10 +87,7 @@ export function NativeVideoPlayer({
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => {
|
||||
setShowQuality(false);
|
||||
onQualityChange(q.qn);
|
||||
}}
|
||||
onPress={() => { setShowQuality(false); onQualityChange(q.qn); }}
|
||||
>
|
||||
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
|
||||
{q.desc}
|
||||
@@ -132,45 +104,15 @@ export function NativeVideoPlayer({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
|
||||
webview: { flex: 1, backgroundColor: '#000' },
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
ctrlBtn: {
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
video: { flex: 1 },
|
||||
placeholder: { flex: 1, backgroundColor: '#000' },
|
||||
controls: { position: 'absolute', top: 8, right: 8, flexDirection: 'row', gap: 8 },
|
||||
ctrlBtn: { backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4, alignItems: 'center', justifyContent: 'center' },
|
||||
qualityText: { color: '#fff', fontSize: 12, 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,
|
||||
},
|
||||
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',
|
||||
},
|
||||
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' },
|
||||
});
|
||||
|
||||
276
docs/superpowers/plans/2026-03-10-dash-1080p.md
Normal file
276
docs/superpowers/plans/2026-03-10-dash-1080p.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# DASH 1080P Playback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 Android 上解锁 1080P+ 画质,通过切换至 DASH 格式并生成合法 MPD data URI 传给 ExoPlayer。
|
||||
|
||||
**Architecture:** `getPlayUrl` 按平台分叉——Android 使用 `fnval=16` 获取 DASH JSON(含分离的 video/audio 流及 segmentBase 范围);iOS/Web 保持 `fnval=0,platform=html5`(720P MP4)。DASH 数据在 `utils/dash.ts` 中组装成标准 MPD XML,经 `btoa` 编码为 `data:` URI,传给 `react-native-video` 的 ExoPlayer 播放。
|
||||
|
||||
**Tech Stack:** React Native 0.83, Expo SDK 55, react-native-video 6.x (ExoPlayer on Android), TypeScript
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|---|---|---|
|
||||
| `services/types.ts` | Modify | 扩展 dash video/audio 条目类型,加 segmentBase/width/height/mimeType/frameRate |
|
||||
| `services/bilibili.ts` | Modify | `getPlayUrl` 按 Platform.OS 分叉 fnval |
|
||||
| `utils/dash.ts` | **Create** | `buildDashDataUri(playData, qn): string` |
|
||||
| `components/NativeVideoPlayer.tsx` | Modify | 有 dash 时用 data URI + `type="mpd"`,否则走原 durl 路径 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 扩展 PlayUrlResponse 类型
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/types.ts`
|
||||
|
||||
- [ ] **Step 1: 更新 dash video/audio 条目类型,加入 B 站实际返回字段**
|
||||
|
||||
```typescript
|
||||
// services/types.ts — 替换现有 PlayUrlResponse
|
||||
|
||||
export interface DashSegmentBase {
|
||||
Initialization: string; // e.g. "0-938"
|
||||
indexRange: string; // e.g. "939-2694"
|
||||
}
|
||||
|
||||
export interface DashVideoItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string; // "video/mp4"
|
||||
codecs: string; // "avc1.640028"
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: string; // "25" or "25000/1000"
|
||||
segmentBase: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface DashAudioItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string; // "audio/mp4"
|
||||
codecs: string; // "mp4a.40.2"
|
||||
segmentBase: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface PlayUrlResponse {
|
||||
durl?: Array<{
|
||||
url: string;
|
||||
length: number;
|
||||
size: number;
|
||||
}>;
|
||||
dash?: {
|
||||
duration: number;
|
||||
video: DashVideoItem[];
|
||||
audio: DashAudioItem[];
|
||||
};
|
||||
quality: number;
|
||||
accept_quality: number[];
|
||||
accept_description: string[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add services/types.ts
|
||||
git commit -m "feat: extend PlayUrlResponse types for DASH segmentBase fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: getPlayUrl 按平台分叉
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/bilibili.ts` (line 97–102)
|
||||
|
||||
- [ ] **Step 1: 修改 `getPlayUrl` 函数**
|
||||
|
||||
将现有函数替换为:
|
||||
|
||||
```typescript
|
||||
export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
const params = isAndroid
|
||||
? { bvid, cid, qn, fnval: 16, fourk: 1 }
|
||||
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
||||
const res = await api.get('/x/player/playurl', { params });
|
||||
return res.data.data as PlayUrlResponse;
|
||||
}
|
||||
```
|
||||
|
||||
注:`Platform` 已在文件顶部从 `react-native` 导入(第 3 行),无需新增 import。
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add services/bilibili.ts
|
||||
git commit -m "feat: use DASH (fnval=16) on Android for getPlayUrl"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 新建 utils/dash.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `utils/dash.ts`
|
||||
|
||||
- [ ] **Step 1: 创建文件,实现 `buildDashDataUri`**
|
||||
|
||||
```typescript
|
||||
// utils/dash.ts
|
||||
import type { PlayUrlResponse } from '../services/types';
|
||||
|
||||
/**
|
||||
* 从 Bilibili DASH 响应生成 MPD data URI。
|
||||
* 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。
|
||||
* 返回 "data:application/dash+xml;base64,..." 供 react-native-video 使用。
|
||||
*/
|
||||
export function buildDashDataUri(playData: PlayUrlResponse, qn: number): string {
|
||||
const dash = playData.dash!;
|
||||
|
||||
const video =
|
||||
dash.video.find(v => v.id === qn) ?? dash.video[0];
|
||||
const audio = dash.audio.reduce((best, a) =>
|
||||
a.bandwidth > best.bandwidth ? a : best
|
||||
);
|
||||
|
||||
const dur = dash.duration;
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"
|
||||
profiles="urn:mpeg:dash:profile:isoff-on-demand:2011"
|
||||
type="static"
|
||||
mediaPresentationDuration="PT${dur}S">
|
||||
<Period duration="PT${dur}S">
|
||||
<AdaptationSet id="1" mimeType="${video.mimeType}" codecs="${video.codecs}" startWithSAP="1" subsegmentAlignment="true">
|
||||
<Representation id="v1" bandwidth="${video.bandwidth}" width="${video.width}" height="${video.height}" frameRate="${video.frameRate}">
|
||||
<BaseURL>${escapeXml(video.baseUrl)}</BaseURL>
|
||||
<SegmentBase indexRange="${video.segmentBase.indexRange}">
|
||||
<Initialization range="${video.segmentBase.Initialization}"/>
|
||||
</SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
<AdaptationSet id="2" mimeType="${audio.mimeType}" codecs="${audio.codecs}" startWithSAP="1" subsegmentAlignment="true">
|
||||
<Representation id="a1" bandwidth="${audio.bandwidth}">
|
||||
<BaseURL>${escapeXml(audio.baseUrl)}</BaseURL>
|
||||
<SegmentBase indexRange="${audio.segmentBase.indexRange}">
|
||||
<Initialization range="${audio.segmentBase.Initialization}"/>
|
||||
</SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>`;
|
||||
|
||||
// btoa is available in React Native's Hermes JS engine
|
||||
return `data:application/dash+xml;base64,${btoa(xml)}`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add utils/dash.ts
|
||||
git commit -m "feat: add buildDashDataUri utility for DASH MPD generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 更新 NativeVideoPlayer 使用 DASH data URI
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/NativeVideoPlayer.tsx`
|
||||
|
||||
- [ ] **Step 1: 导入 `buildDashDataUri`,更新 url 选取逻辑**
|
||||
|
||||
在文件顶部 import 区域添加:
|
||||
```typescript
|
||||
import { buildDashDataUri } from '../utils/dash';
|
||||
```
|
||||
|
||||
将第 34 行的 url 计算替换为(在组件函数体内):
|
||||
|
||||
```typescript
|
||||
// 优先使用 DASH data URI(Android),降级使用 durl MP4(iOS)
|
||||
const url = playData?.dash
|
||||
? buildDashDataUri(playData, currentQn)
|
||||
: playData?.durl?.[0]?.url;
|
||||
```
|
||||
|
||||
将 `<Video>` 组件的 `source` prop 更新,DASH 时传入 `type` hint:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={
|
||||
playData?.dash
|
||||
? { uri: url!, type: 'mpd', headers: BILIBILI_HEADERS }
|
||||
: { uri: url!, headers: BILIBILI_HEADERS }
|
||||
}
|
||||
style={styles.video}
|
||||
resizeMode="contain"
|
||||
controls
|
||||
paused={false}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 手动测试清单**
|
||||
|
||||
在 Android 设备 / 模拟器上:
|
||||
1. `expo run:android`(需要 dev build,react-native-video 是 native 模块)
|
||||
2. 打开任意视频详情页
|
||||
3. 确认播放器加载且**有声音有画面**
|
||||
4. 点击右上角清晰度按钮,检查可选项是否包含 1080P(80)或更高
|
||||
5. 切换清晰度,确认切换后正常播放
|
||||
6. (可选)登录后重新打开视频,确认 `accept_quality` 中出现更高画质选项
|
||||
|
||||
在 iOS(如有设备):
|
||||
1. 确认退化到 720P MP4 durl 路径,视频正常播放无报错
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add components/NativeVideoPlayer.tsx
|
||||
git commit -m "feat: play DASH streams on Android for 1080P+ quality"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标志
|
||||
|
||||
- [ ] Android 上视频清晰度列表出现 1080P(qn=80)及以上选项
|
||||
- [ ] 切换清晰度后视频带音频正常播放
|
||||
- [ ] iOS 仍可正常播放(720P durl 路径)
|
||||
- [ ] `npx tsc --noEmit` 无类型错误
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"react-native": "0.83.2",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"zustand": "^5.0.11"
|
||||
@@ -7756,6 +7757,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-video": {
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.19.0.tgz",
|
||||
"integrity": "sha512-JVojWIxwuH5C3RjVrF4UyuweuOH/Guq/W2xeN9zugePXZI8Xn/j6/oU94gCWHaFzkR/HGeJpqMq+l9aEHSnpIQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"react-native": "0.83.2",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"zustand": "^5.0.11"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo } from './types';
|
||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, HeatmapResponse } from './types';
|
||||
import { signWbi } from '../utils/wbi';
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
@@ -95,10 +95,11 @@ export async function getVideoDetail(bvid: string): Promise<VideoItem> {
|
||||
}
|
||||
|
||||
export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
|
||||
const fnval = qn >= 80 ? 16 : 0;
|
||||
const res = await api.get('/x/player/playurl', {
|
||||
params: { bvid, cid, qn, fnval, platform: 'html5', fourk: 1 },
|
||||
});
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
const params = isAndroid
|
||||
? { bvid, cid, qn, fnval: 16, fourk: 1 }
|
||||
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
||||
const res = await api.get('/x/player/playurl', { params });
|
||||
return res.data.data as PlayUrlResponse;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,22 @@ export async function getComments(aid: number, pn = 1): Promise<Comment[]> {
|
||||
return (res.data.data?.replies ?? []) as Comment[];
|
||||
}
|
||||
|
||||
export async function getHeatmap(bvid: string): Promise<HeatmapResponse | null> {
|
||||
try {
|
||||
const res = await api.get('/pbp/data', { params: { bvid } });
|
||||
return res.data.data as HeatmapResponse;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function getVideoShot(bvid: string, cid: number): Promise<VideoShotData | null> {
|
||||
try {
|
||||
const res = await api.get('/x/player/videoshot', {
|
||||
params: { bvid, cid, index: 1 },
|
||||
});
|
||||
return res.data.data as VideoShotData;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function generateQRCode(): Promise<QRCodeInfo> {
|
||||
const headers = isWeb
|
||||
? {}
|
||||
|
||||
@@ -34,6 +34,32 @@ export interface Comment {
|
||||
replies: Comment[] | null;
|
||||
}
|
||||
|
||||
export interface DashSegmentBase {
|
||||
Initialization: string;
|
||||
indexRange: string;
|
||||
}
|
||||
|
||||
export interface DashVideoItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string;
|
||||
codecs: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: string;
|
||||
segmentBase: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface DashAudioItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string;
|
||||
codecs: string;
|
||||
segmentBase: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface PlayUrlResponse {
|
||||
durl?: Array<{
|
||||
url: string;
|
||||
@@ -41,8 +67,9 @@ export interface PlayUrlResponse {
|
||||
size: number;
|
||||
}>;
|
||||
dash?: {
|
||||
video: Array<{ id: number; baseUrl: string; codecs: string; bandwidth: number }>;
|
||||
audio: Array<{ id: number; baseUrl: string; codecs: string; bandwidth: number }>;
|
||||
duration: number;
|
||||
video: DashVideoItem[];
|
||||
audio: DashAudioItem[];
|
||||
};
|
||||
quality: number;
|
||||
accept_quality: number[];
|
||||
@@ -53,3 +80,16 @@ export interface QRCodeInfo {
|
||||
url: string;
|
||||
qrcode_key: string;
|
||||
}
|
||||
|
||||
export interface VideoShotData {
|
||||
img_x_len: number;
|
||||
img_y_len: number;
|
||||
img_x_size: number;
|
||||
img_y_size: number;
|
||||
image: string[];
|
||||
}
|
||||
|
||||
export interface HeatmapResponse {
|
||||
timestamp: number;
|
||||
pb_data: string;
|
||||
}
|
||||
|
||||
52
utils/dash.ts
Normal file
52
utils/dash.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { PlayUrlResponse } from '../services/types';
|
||||
|
||||
/**
|
||||
* 从 Bilibili DASH 响应生成 MPD data URI。
|
||||
* 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。
|
||||
* 返回 "data:application/dash+xml;base64,..." 供 react-native-video (ExoPlayer) 使用。
|
||||
*/
|
||||
export function buildDashDataUri(playData: PlayUrlResponse, qn: number): string {
|
||||
const dash = playData.dash!;
|
||||
|
||||
const video = dash.video.find(v => v.id === qn) ?? dash.video[0];
|
||||
const audio = dash.audio.reduce((best, a) =>
|
||||
a.bandwidth > best.bandwidth ? a : best
|
||||
);
|
||||
|
||||
const dur = dash.duration;
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"
|
||||
profiles="urn:mpeg:dash:profile:isoff-on-demand:2011"
|
||||
type="static"
|
||||
mediaPresentationDuration="PT${dur}S">
|
||||
<Period duration="PT${dur}S">
|
||||
<AdaptationSet id="1" mimeType="${video.mimeType}" codecs="${video.codecs}" startWithSAP="1" subsegmentAlignment="true">
|
||||
<Representation id="v1" bandwidth="${video.bandwidth}" width="${video.width}" height="${video.height}" frameRate="${video.frameRate}">
|
||||
<BaseURL>${escapeXml(video.baseUrl)}</BaseURL>
|
||||
<SegmentBase indexRange="${video.segmentBase.indexRange}">
|
||||
<Initialization range="${video.segmentBase.Initialization}"/>
|
||||
</SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
<AdaptationSet id="2" mimeType="${audio.mimeType}" codecs="${audio.codecs}" startWithSAP="1" subsegmentAlignment="true">
|
||||
<Representation id="a1" bandwidth="${audio.bandwidth}">
|
||||
<BaseURL>${escapeXml(audio.baseUrl)}</BaseURL>
|
||||
<SegmentBase indexRange="${audio.segmentBase.indexRange}">
|
||||
<Initialization range="${audio.segmentBase.Initialization}"/>
|
||||
</SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>`;
|
||||
|
||||
return `data:application/dash+xml;base64,${btoa(xml)}`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user