| name | jkvideo-bilibili-react-native |
| description | Expert skill for building and extending JKVideo, a React Native Bilibili-like client with DASH playback, danmaku, WBI signing, and live streaming |
| triggers | ["build a bilibili react native app","add DASH video playback to expo","implement danmaku bullet comments","WBI signature bilibili API","react native live streaming with websocket danmaku","expo video player with multiple quality levels","bilibili API integration typescript","react native download manager with LAN sharing"] |
JKVideo Bilibili React Native Client
Skill by ara.so — Daily 2026 Skills collection.
JKVideo is a full-featured third-party Bilibili client built with React Native 0.83 + Expo SDK 55. It supports DASH adaptive streaming, real-time danmaku (bullet comments), WBI API signing, QR code login, live streaming with WebSocket danmaku, and a download manager with LAN QR sharing.
Installation & Setup
Prerequisites
- Node.js 18+
- npm or yarn
- For Android: Android Studio + SDK
- For iOS: macOS + Xcode 15+
Quick Start (Expo Go — no compilation)
git clone https://github.com/tiajinsha/JKVideo.git
cd JKVideo
npm install
npx expo start
Scan the QR with Expo Go app. Note: DASH 1080P+ requires Dev Build.
Dev Build (Full Features — Recommended)
npm install
npx expo run:android
npx expo run:ios
Web with Image Proxy
npm install
npx expo start --web
node scripts/proxy.js
Install APK Directly (Android)
Download from Releases — enable "Install from unknown sources" in Android settings.
Project Structure
app/
index.tsx # Home (PagerView hot/live tabs)
video/[bvid].tsx # Video detail (playback + comments + danmaku)
live/[roomId].tsx # Live detail (HLS + real-time danmaku)
search.tsx # Search page
downloads.tsx # Download manager
settings.tsx # Settings (quality, logout)
components/ # UI: player, danmaku overlay, cards
hooks/ # Data hooks: video list, streams, danmaku
services/ # Bilibili API (axios + cookie interceptor)
store/ # Zustand stores: auth, download, video, settings
utils/ # Helpers: format, image proxy, MPD builder
Key Technology Stack
| Layer | Technology |
|---|
| Framework | React Native 0.83 + Expo SDK 55 |
| Routing | expo-router v4 (file-system, Stack nav) |
| State | Zustand |
| HTTP | Axios |
| Storage | @react-native-async-storage/async-storage |
| Video | react-native-video (DASH MPD / HLS / MP4) |
| Fallback | react-native-webview (HTML5 video injection) |
| Paging | react-native-pager-view |
| Icons | @expo/vector-icons (Ionicons) |
WBI Signature Implementation
Bilibili requires WBI signing for most API calls. JKVideo implements pure TypeScript MD5 with 12h nav cache.
const MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13
];
function getMixinKey(rawKey: string): string {
return MIXIN_KEY_ENC_TAB
.map(i => rawKey[i])
.join('')
.slice(0, 32);
}
export async function signWbi(
params: Record<, | >,
: ,
:
): <<, | >> {
mixinKey = (imgKey + subKey);
wts = .(.() / );
signParams = { ...params, wts };
query = .(signParams)
.()
.( {
val = (signParams[k]).(, );
;
})
.();
wRid = (query + mixinKey);
{ ...signParams, : wRid };
}
(): <{ : ; : }> {
cached = .();
(cached) {
{ keys, ts } = .(cached);
(.() - ts < * * ) keys;
}
res = api.();
{ img_url, sub_url } = res...;
imgKey = img_url.().()!.(, );
subKey = sub_url.().()!.(, );
keys = { imgKey, subKey };
.(, .({ keys, : .() }));
keys;
}
Bilibili API Service
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const api = axios.create({
baseURL: 'https://api.bilibili.com',
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36',
'Referer': 'https://www.bilibili.com',
},
});
api.interceptors.request.use(async (config) => {
const sessdata = await AsyncStorage.getItem('SESSDATA');
if (sessdata) {
config.headers['Cookie'] = `SESSDATA=${sessdata}`;
}
return config;
});
export async function getPopularVideos(pn = 1, ps = 20) {
const { imgKey, subKey } = await getWbiKeys();
const signed = await signWbi({ pn, ps }, imgKey, subKey);
res = api.(, { : signed });
res...;
}
() {
{ imgKey, subKey } = ();
signed = (
{ bvid, cid, qn, : , : , : },
imgKey, subKey
);
res = api.(, { : signed });
res..;
}
() {
res = api.(, {
: { : roomId, : , : },
: ,
});
res...[].;
}
DASH MPD Builder
ExoPlayer needs a local MPD file. JKVideo generates it from Bilibili's DASH response:
export function buildDashMpdUri(dashData: BiliDashData): string {
const { duration, video, audio } = dashData;
const videoAdaptations = video.map((v) => `
<AdaptationSet mimeType="video/mp4" segmentAlignment="true">
<Representation id="${v.id}" bandwidth="${v.bandwidth}"
codecs="${v.codecs}" width="${v.width}" height="${v.height}">
<BaseURL>${escapeXml(v.baseUrl)}</BaseURL>
<SegmentBase indexRange="${v.segmentBase.indexRange}">
<Initialization range="${v.segmentBase.initialization}"/>
</SegmentBase>
</Representation>
</AdaptationSet>`).join('');
const audioAdaptations = audio.map((a) => `
<AdaptationSet mimeType="audio/mp4" segmentAlignment="true">
<Representation id="${a.id}" bandwidth="${a.bandwidth}" codecs="${a.codecs}">
<BaseURL>${escapeXml(a.baseUrl)}</BaseURL>
<SegmentBase indexRange="${a.segmentBase.indexRange}">
<Initialization range="${a.segmentBase.initialization}"/>
</SegmentBase>
</Representation>
</AdaptationSet>`).join('');
const mpd = `<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static"
mediaPresentationDuration="PT${duration}S" minBufferTime="PT1.5S">
<Period duration="PTS">
</Period>
</MPD>`;
path = ;
.(path, mpd);
path;
}
Video Player Component
import Video from 'react-native-video';
import { WebView } from 'react-native-webview';
import { useVideoStore } from '../store/videoStore';
interface VideoPlayerProps {
bvid: string;
cid: number;
autoPlay?: boolean;
}
export function VideoPlayer({ bvid, cid, autoPlay = false }: VideoPlayerProps) {
const [mpdUri, setMpdUri] = useState<string | null>(null);
const [useFallback, setUseFallback] = useState(false);
const { setCurrentVideo } = useVideoStore();
useEffect(() => {
loadStream();
}, [bvid, cid]);
async function loadStream() {
try {
const stream = await getVideoStream(bvid, cid);
if (stream.dash) {
const uri = await (stream.);
(uri);
} {
();
}
} {
();
}
}
(useFallback) {
(
);
}
(
);
}
Danmaku System
Video Danmaku (XML timeline sync)
export function useDanmaku(cid: number) {
const [danmakuList, setDanmakuList] = useState<Danmaku[]>([]);
useEffect(() => {
fetchDanmaku(cid);
}, [cid]);
async function fetchDanmaku(cid: number) {
const res = await api.get(`/x/v1/dm/list.so?oid=${cid}`, {
responseType: 'arraybuffer',
});
const xml = new TextDecoder('utf-8').decode(res.data);
const items = parseXmlDanmaku(xml);
setDanmakuList(items);
}
return danmakuList;
}
const LANE_COUNT = 5;
export function DanmakuOverlay({ danmakuList, currentTime }: Props) {
const activeDanmaku = danmakuList.filter(
=> d. >= currentTime - && d. < currentTime +
);
(
);
}
Live Danmaku (WebSocket)
const LIVE_WS = 'wss://broadcastlv.chat.bilibili.com/sub';
export function useLiveDanmaku(roomId: number) {
const [messages, setMessages] = useState<LiveMessage[]>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(LIVE_WS);
wsRef.current = ws;
ws.onopen = () => {
const body = JSON.stringify({ roomid: roomId, uid: 0, protover: 2 });
ws.send(buildPacket(body, 7));
startHeartbeat(ws);
};
ws.onmessage = async (event) => {
const packets = await decompressPacket(event.data);
packets.forEach(packet => {
if (packet. === ) {
msg = .(packet.);
(msg);
}
});
};
ws.();
}, [roomId]);
() {
(msg.) {
:
( [{
: ,
: msg.[],
: msg.[][],
: msg.[] > ,
}, ...prev].(, ));
;
:
( [{
: ,
: msg..,
: msg..,
: msg..,
}, ...prev].(, ));
;
}
}
messages;
}
Zustand State Stores
import { create } from 'zustand';
interface VideoState {
currentVideo: { bvid: string; cid: number } | null;
isMiniplayer: boolean;
quality: number;
setCurrentVideo: (video: { bvid: string; cid: number }) => void;
setMiniplayer: (val: boolean) => void;
setQuality: (q: number) => void;
}
export const useVideoStore = create<VideoState>((set) => ({
currentVideo: null,
isMiniplayer: false,
quality: 80,
setCurrentVideo: (video) => set({ currentVideo: video }),
setMiniplayer: (val) => ({ : val }),
: ({ : q }),
}));
{
: | ;
: ;
: <>;
: <>;
}
useAuthStore = create<>( ({
: ,
: ,
: (sessdata) => {
.(, sessdata);
({ sessdata, : });
},
: () => {
.();
({ : , : });
},
}));
QR Code Login
export function useQrLogin() {
const { login } = useAuthStore();
const [qrUrl, setQrUrl] = useState('');
const [qrKey, setQrKey] = useState('');
const pollRef = useRef<ReturnType<typeof setInterval>>();
async function generateQr() {
const res = await api.get('/x/passport-login/web/qrcode/generate');
const { url, qrcode_key } = res.data.data;
setQrUrl(url);
setQrKey(qrcode_key);
startPolling(qrcode_key);
}
function startPolling(key: string) {
pollRef.current = setInterval(async () => {
const res = await api.get('/x/passport-login/web/qrcode/poll', {
params: { qrcode_key: key },
});
const { code } = res.data.data;
if (code === ) {
setCookie = res.[] ?? [];
sessdataCookie = setCookie
.( c.());
sessdata = sessdataCookie?.()?.[];
(sessdata) {
(sessdata);
(pollRef.);
}
}
}, );
}
( (pollRef.), []);
{ qrUrl, generateQr };
}
Download Manager + LAN Sharing
import * as FileSystem from 'expo-file-system';
export const useDownloadStore = create((set, get) => ({
downloads: [] as Download[],
startDownload: async (bvid: string, quality: number) => {
const stream = await getVideoStream(bvid, 0, quality);
const url = stream.durl?.[0]?.url ?? stream.dash?.video?.[0]?.baseUrl;
const path = `${FileSystem.documentDirectory}downloads/${bvid}_${quality}.mp4`;
const task = FileSystem.createDownloadResumable(url, path, {
headers: { Referer: 'https://www.bilibili.com' },
}, (progress) => {
set(state => ({
downloads: state..(
d. === bvid ? { ...d, : progress. / progress. } : d
),
}));
});
( ({
: [...state., { bvid, quality, path, : , task }],
}));
task.();
},
}));
{ createServer } ;
{ networkInterfaces } ;
(): {
nets = ();
( name .(nets)) {
( net nets[name]!) {
(net. === && !net.) net.;
}
}
;
}
expo-router Navigation Patterns
import { useLocalSearchParams } from 'expo-router';
export default function VideoDetail() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
}
import { router } from 'expo-router';
router.push(`/video/${bvid}`);
router.push(`/live/${roomId}`);
router.back();
Image Proxy (Web)
Bilibili images block direct loading in browsers via Referer header. Use the bundled proxy:
function proxyImage(url: string): string {
if (Platform.OS === 'web') {
return `http://localhost:3001/proxy?url=${encodeURIComponent(url)}`;
}
return url;
}
Quality Level Reference
| Code | Quality |
|---|
| 16 | 360P |
| 32 | 480P |
| 64 | 720P |
| 80 | 1080P |
| 112 | 1080P+ (大会员) |
| 116 | 1080P60 (大会员) |
| 120 | 4K (大会员) |
Common Patterns
Add a New API Endpoint
export async function getVideoInfo(bvid: string) {
const { imgKey, subKey } = await getWbiKeys();
const signed = await signWbi({ bvid }, imgKey, subKey);
const res = await api.get('/x/web-interface/view', { params: signed });
return res.data.data;
}
Add a New Screen
import { Stack } from 'expo-router';
export default function HistoryScreen() {
return (
<>
<Stack.Screen options={{ title: '历史记录' }} />
{/* screen content */}
</>
);
}
Create a Zustand Slice
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const useSettingsStore = create(
persist(
(set) => ({
defaultQuality: 80,
danmakuEnabled: true,
setDefaultQuality: (q: number) => set({ defaultQuality: q }),
toggleDanmaku: () => set(s => ({ danmakuEnabled: !s.danmakuEnabled })),
}),
{
name: 'jkvideo-settings',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
Troubleshooting
| Issue | Solution |
|---|
| DASH not playing in Expo Go | Use Dev Build: npx expo run:android |
| Images not loading on Web | Run node scripts/proxy.js and ensure web uses proxy URLs |
| API returns 412 / risk control | Ensure WBI signature is fresh; clear cached keys via AsyncStorage |
| 4K/1080P+ not available | Login with a Bilibili Premium (大会员) account |
| Live stream fails | App auto-selects HLS; FLV is not supported by ExoPlayer/HTML5 |
| QR code expired | Close and reopen the login modal to regenerate |
| Cookie not persisting | Check AsyncStorage permissions; SESSDATA key must match interceptor |
| WebSocket danmaku drops | Increase heartbeat frequency; check packet decompression (zlib) |
| Build fails on iOS | Run cd ios && pod install then rebuild |
Known Limitations
- Dynamic feed / posting / likes require
bili_jct CSRF token — not yet implemented
- FLV live streams are not supported; app auto-selects HLS fallback
- Web platform requires local proxy server for images (Referer restriction)
- 4K / 1080P+ requires logged-in Bilibili Premium account
- QR code expires after 10 minutes — reopen modal to refresh