fix: switch DASH delivery from data: URI to local MPD file via expo-file-system

data: URI scheme for DASH manifests is unreliable in ExoPlayer.
Write MPD XML to FileSystem.cacheDirectory/bili_dash.mpd and pass
file:// URI to react-native-video instead. URL resolution is now
async via useEffect+state in NativeVideoPlayer.
This commit is contained in:
Developer
2026-03-10 20:16:01 +08:00
parent 911eeb9052
commit 35371cebff
4 changed files with 41 additions and 26 deletions

View File

@@ -6,7 +6,7 @@ import {
import Video, { VideoRef } from 'react-native-video'; import Video, { VideoRef } from 'react-native-video';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import type { PlayUrlResponse } from '../services/types'; import type { PlayUrlResponse } from '../services/types';
import { buildDashDataUri } from '../utils/dash'; import { buildDashMpdUri } from '../utils/dash';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const VIDEO_HEIGHT = width * 0.5625; const VIDEO_HEIGHT = width * 0.5625;
@@ -33,12 +33,21 @@ export function NativeVideoPlayer({
onProgress, seekTo, onProgress, seekTo,
}: Props) { }: Props) {
const [showQuality, setShowQuality] = useState(false); const [showQuality, setShowQuality] = useState(false);
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
const videoRef = useRef<VideoRef>(null); const videoRef = useRef<VideoRef>(null);
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? (currentQn ? String(currentQn) : 'HD'); const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? (currentQn ? String(currentQn) : 'HD');
const isDash = !!playData?.dash; const isDash = !!playData?.dash;
const url = isDash
? buildDashDataUri(playData!, currentQn) useEffect(() => {
: playData?.durl?.[0]?.url; if (!playData) { setResolvedUrl(undefined); return; }
if (isDash) {
buildDashMpdUri(playData, currentQn).then(setResolvedUrl).catch(() => {
setResolvedUrl(playData.dash!.video[0]?.baseUrl);
});
} else {
setResolvedUrl(playData.durl?.[0]?.url);
}
}, [playData, currentQn]);
useEffect(() => { useEffect(() => {
if (seekTo !== undefined) videoRef.current?.seek(seekTo.t); if (seekTo !== undefined) videoRef.current?.seek(seekTo.t);
@@ -46,12 +55,12 @@ export function NativeVideoPlayer({
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
{url ? ( {resolvedUrl ? (
<Video <Video
ref={videoRef} ref={videoRef}
source={isDash source={isDash
? { uri: url, type: 'mpd', headers: BILIBILI_HEADERS } ? { uri: resolvedUrl, type: 'mpd', headers: BILIBILI_HEADERS }
: { uri: url, headers: BILIBILI_HEADERS } : { uri: resolvedUrl, headers: BILIBILI_HEADERS }
} }
style={styles.video} style={styles.video}
resizeMode="contain" resizeMode="contain"

21
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"expo": "~55.0.5", "expo": "~55.0.5",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-dev-client": "~55.0.11", "expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10",
"expo-linear-gradient": "~55.0.8", "expo-linear-gradient": "~55.0.8",
"expo-router": "~55.0.4", "expo-router": "~55.0.4",
"expo-status-bar": "~55.0.4", "expo-status-bar": "~55.0.4",
@@ -4397,6 +4398,16 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-file-system": {
"version": "55.0.10",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.10.tgz",
"integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-font": { "node_modules/expo-font": {
"version": "55.0.4", "version": "55.0.4",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.4.tgz", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.4.tgz",
@@ -4974,16 +4985,6 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo/node_modules/expo-file-system": {
"version": "55.0.10",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.10.tgz",
"integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-keep-awake": { "node_modules/expo/node_modules/expo-keep-awake": {
"version": "55.0.4", "version": "55.0.4",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz",

View File

@@ -16,6 +16,7 @@
"expo": "~55.0.5", "expo": "~55.0.5",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-dev-client": "~55.0.11", "expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10",
"expo-linear-gradient": "~55.0.8", "expo-linear-gradient": "~55.0.8",
"expo-router": "~55.0.4", "expo-router": "~55.0.4",
"expo-status-bar": "~55.0.4", "expo-status-bar": "~55.0.4",

View File

@@ -1,11 +1,18 @@
import * as FileSystem from 'expo-file-system/legacy';
import type { PlayUrlResponse } from '../services/types'; import type { PlayUrlResponse } from '../services/types';
/** /**
* Bilibili DASH 响应成 MPD data URI * Bilibili DASH 响应成 MPD 文件,返回 file:// URI 供 ExoPlayer 播放
* 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。 * 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。
* 返回 "data:application/dash+xml;base64,..." 供 react-native-video (ExoPlayer) 使用。
*/ */
export function buildDashDataUri(playData: PlayUrlResponse, qn: number): string { export async function buildDashMpdUri(playData: PlayUrlResponse, qn: number): Promise<string> {
const xml = buildMpdXml(playData, qn);
const path = `${FileSystem.cacheDirectory}bili_dash.mpd`;
await FileSystem.writeAsStringAsync(path, xml, { encoding: FileSystem.EncodingType.UTF8 });
return path;
}
function buildMpdXml(playData: PlayUrlResponse, qn: number): string {
const dash = playData.dash!; const dash = playData.dash!;
const video = dash.video.find(v => v.id === qn) ?? dash.video[0]; const video = dash.video.find(v => v.id === qn) ?? dash.video[0];
@@ -14,18 +21,17 @@ export function buildDashDataUri(playData: PlayUrlResponse, qn: number): string
); );
const dur = dash.duration; const dur = dash.duration;
const vSeg = video.segment_base; const vSeg = video.segment_base;
const aSeg = audio.segment_base; const aSeg = audio.segment_base;
const videoSegmentBase = vSeg const videoSegmentBase = vSeg
? `\n <SegmentBase indexRange="${vSeg.index_range}">\n <Initialization range="${vSeg.initialization}"/>\n </SegmentBase>` ? `\n <SegmentBase indexRange="${vSeg.index_range}"><Initialization range="${vSeg.initialization}"/></SegmentBase>`
: ''; : '';
const audioSegmentBase = aSeg const audioSegmentBase = aSeg
? `\n <SegmentBase indexRange="${aSeg.index_range}">\n <Initialization range="${aSeg.initialization}"/>\n </SegmentBase>` ? `\n <SegmentBase indexRange="${aSeg.index_range}"><Initialization range="${aSeg.initialization}"/></SegmentBase>`
: ''; : '';
const xml = `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" <MPD xmlns="urn:mpeg:dash:schema:mpd:2011"
profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011"
type="static" type="static"
@@ -43,8 +49,6 @@ export function buildDashDataUri(playData: PlayUrlResponse, qn: number): string
</AdaptationSet> </AdaptationSet>
</Period> </Period>
</MPD>`; </MPD>`;
return `data:application/dash+xml;base64,${btoa(xml)}`;
} }
function escapeXml(s: string): string { function escapeXml(s: string): string {