Trending-skills jkvideo-bilibili-react-native
Expert skill for building and extending JKVideo, a React Native Bilibili-like client with DASH playback, danmaku, WBI signing, and live streaming
git clone https://github.com/Aradotso/trending-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/Aradotso/trending-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/jkvideo-bilibili-react-native" ~/.claude/skills/aradotso-trending-skills-jkvideo-bilibili-react-native && rm -rf "$T"
skills/jkvideo-bilibili-react-native/SKILL.mdJKVideo 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 # Android npx expo run:ios # iOS (macOS + Xcode required)
Web with Image Proxy
npm install npx expo start --web # In a separate terminal: node scripts/proxy.js # Starts proxy on port 3001 to bypass Bilibili referer restrictions
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.
// utils/wbi.ts — pure TS MD5, no external crypto deps 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<string, string | number>, imgKey: string, subKey: string ): Promise<Record<string, string | number>> { const mixinKey = getMixinKey(imgKey + subKey); const wts = Math.floor(Date.now() / 1000); const signParams = { ...params, wts }; // Sort params alphabetically, filter special chars const query = Object.keys(signParams) .sort() .map(k => { const val = String(signParams[k]).replace(/[!'()*]/g, ''); return `${encodeURIComponent(k)}=${encodeURIComponent(val)}`; }) .join('&'); const wRid = md5(query + mixinKey); // pure TS md5 return { ...signParams, w_rid: wRid }; } // Fetch and cache nav keys (12h TTL) export async function getWbiKeys(): Promise<{ imgKey: string; subKey: string }> { const cached = await AsyncStorage.getItem('wbi_keys'); if (cached) { const { keys, ts } = JSON.parse(cached); if (Date.now() - ts < 12 * 3600 * 1000) return keys; } const res = await api.get('/x/web-interface/nav'); const { img_url, sub_url } = res.data.data.wbi_img; const imgKey = img_url.split('/').pop()!.replace('.png', ''); const subKey = sub_url.split('/').pop()!.replace('.png', ''); const keys = { imgKey, subKey }; await AsyncStorage.setItem('wbi_keys', JSON.stringify({ keys, ts: Date.now() })); return keys; }
Bilibili API Service
// services/api.ts 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', }, }); // Inject SESSDATA cookie from store api.interceptors.request.use(async (config) => { const sessdata = await AsyncStorage.getItem('SESSDATA'); if (sessdata) { config.headers['Cookie'] = `SESSDATA=${sessdata}`; } return config; }); // Popular video list (WBI signed) export async function getPopularVideos(pn = 1, ps = 20) { const { imgKey, subKey } = await getWbiKeys(); const signed = await signWbi({ pn, ps }, imgKey, subKey); const res = await api.get('/x/web-interface/popular', { params: signed }); return res.data.data.list; } // Video stream info (DASH) export async function getVideoStream(bvid: string, cid: number, qn = 80) { const { imgKey, subKey } = await getWbiKeys(); const signed = await signWbi( { bvid, cid, qn, fnval: 4048, fnver: 0, fourk: 1 }, imgKey, subKey ); const res = await api.get('/x/player/wbi/playurl', { params: signed }); return res.data.data; } // Live stream URL export async function getLiveStreamUrl(roomId: number) { const res = await api.get('/room/v1/Room/playUrl', { params: { cid: roomId, quality: 4, platform: 'h5' }, baseURL: 'https://api.live.bilibili.com', }); return res.data.data.durl[0].url; // HLS m3u8 }
DASH MPD Builder
ExoPlayer needs a local MPD file. JKVideo generates it from Bilibili's DASH response:
// utils/buildDashMpd.ts 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="PT${duration}S"> ${videoAdaptations} ${audioAdaptations} </Period> </MPD>`; // Write to temp file, return file:// URI for ExoPlayer const path = `${FileSystem.cacheDirectory}dash_${Date.now()}.mpd`; FileSystem.writeAsStringAsync(path, mpd); return path; }
Video Player Component
// components/VideoPlayer.tsx 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 buildDashMpdUri(stream.dash); setMpdUri(uri); } else { setUseFallback(true); } } catch { setUseFallback(true); } } if (useFallback) { // WebView fallback for Expo Go / Web return ( <WebView source={{ uri: `https://www.bilibili.com/video/${bvid}` }} allowsInlineMediaPlayback mediaPlaybackRequiresUserAction={false} /> ); } return ( <Video source={{ uri: mpdUri! }} style={{ width: '100%', aspectRatio: 16 / 9 }} controls paused={!autoPlay} resizeMode="contain" onLoad={() => setCurrentVideo({ bvid, cid })} /> ); }
Danmaku System
Video Danmaku (XML timeline sync)
// hooks/useDanmaku.ts 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', }); // Parse XML danmaku const xml = new TextDecoder('utf-8').decode(res.data); const items = parseXmlDanmaku(xml); // parse <d p="time,...">text</d> setDanmakuList(items); } return danmakuList; } // components/DanmakuOverlay.tsx — 5-lane floating display const LANE_COUNT = 5; export function DanmakuOverlay({ danmakuList, currentTime }: Props) { const activeDanmaku = danmakuList.filter( d => d.time >= currentTime - 0.1 && d.time < currentTime + 0.1 ); return ( <View style={StyleSheet.absoluteFillObject} pointerEvents="none"> {activeDanmaku.map((d, i) => ( <DanmakuItem key={d.id} text={d.text} color={d.color} lane={i % LANE_COUNT} /> ))} </View> ); }
Live Danmaku (WebSocket)
// hooks/useLiveDanmaku.ts 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 = () => { // Send join room packet const body = JSON.stringify({ roomid: roomId, uid: 0, protover: 2 }); ws.send(buildPacket(body, 7)); // op=7: enter room startHeartbeat(ws); }; ws.onmessage = async (event) => { const packets = await decompressPacket(event.data); packets.forEach(packet => { if (packet.op === 5) { const msg = JSON.parse(packet.body); handleCommand(msg); } }); }; return () => ws.close(); }, [roomId]); function handleCommand(msg: any) { switch (msg.cmd) { case 'DANMU_MSG': setMessages(prev => [{ type: 'danmaku', text: msg.info[1], user: msg.info[2][1], isGuard: msg.info[7] > 0, // 舰长标记 }, ...prev].slice(0, 200)); break; case 'SEND_GIFT': setMessages(prev => [{ type: 'gift', user: msg.data.uname, gift: msg.data.giftName, count: msg.data.num, }, ...prev].slice(0, 200)); break; } } return messages; }
Zustand State Stores
// store/videoStore.ts import { create } from 'zustand'; interface VideoState { currentVideo: { bvid: string; cid: number } | null; isMiniplayer: boolean; quality: number; // 80=1080P, 112=1080P+, 120=4K 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) => set({ isMiniplayer: val }), setQuality: (q) => set({ quality: q }), })); // store/authStore.ts interface AuthState { sessdata: string | null; isLoggedIn: boolean; login: (sessdata: string) => Promise<void>; logout: () => Promise<void>; } export const useAuthStore = create<AuthState>((set) => ({ sessdata: null, isLoggedIn: false, login: async (sessdata) => { await AsyncStorage.setItem('SESSDATA', sessdata); set({ sessdata, isLoggedIn: true }); }, logout: async () => { await AsyncStorage.removeItem('SESSDATA'); set({ sessdata: null, isLoggedIn: false }); }, }));
QR Code Login
// hooks/useQrLogin.ts 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 === 0) { // Extract SESSDATA from Set-Cookie header const setCookie = res.headers['set-cookie'] ?? []; const sessdataCookie = setCookie .find((c: string) => c.includes('SESSDATA=')); const sessdata = sessdataCookie?.match(/SESSDATA=([^;]+)/)?.[1]; if (sessdata) { await login(sessdata); clearInterval(pollRef.current); } } }, 2000); } useEffect(() => () => clearInterval(pollRef.current), []); return { qrUrl, generateQr }; }
Download Manager + LAN Sharing
// store/downloadStore.ts 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, /* cid */ 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) => { // Update progress in store set(state => ({ downloads: state.downloads.map(d => d.bvid === bvid ? { ...d, progress: progress.totalBytesWritten / progress.totalBytesExpectedToWrite } : d ), })); }); set(state => ({ downloads: [...state.downloads, { bvid, quality, path, progress: 0, task }], })); await task.downloadAsync(); }, })); // LAN HTTP server for QR sharing (scripts/proxy.js pattern) // Built-in HTTP server serves downloaded file, generates QR with local IP import { createServer } from 'http'; import { networkInterfaces } from 'os'; function getLanIp(): string { const nets = networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name]!) { if (net.family === 'IPv4' && !net.internal) return net.address; } } return 'localhost'; }
expo-router Navigation Patterns
// app/video/[bvid].tsx import { useLocalSearchParams } from 'expo-router'; export default function VideoDetail() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); // ... } // Navigate to video import { router } from 'expo-router'; router.push(`/video/${bvid}`); // Navigate to live room router.push(`/live/${roomId}`); // Navigate back router.back();
Image Proxy (Web)
Bilibili images block direct loading in browsers via Referer header. Use the bundled proxy:
// scripts/proxy.js (port 3001) // Usage in components: function proxyImage(url: string): string { if (Platform.OS === 'web') { return `http://localhost:3001/proxy?url=${encodeURIComponent(url)}`; } return url; // Native handles Referer correctly }
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
// services/api.ts 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
// app/history.tsx — automatically becomes /history route import { Stack } from 'expo-router'; export default function HistoryScreen() { return ( <> <Stack.Screen options={{ title: '历史记录' }} /> {/* screen content */} </> ); }
Create a Zustand Slice
// store/settingsStore.ts 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: |
| Images not loading on Web | Run 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; key must match interceptor |
| WebSocket danmaku drops | Increase heartbeat frequency; check packet decompression (zlib) |
| Build fails on iOS | Run then rebuild |
Known Limitations
- Dynamic feed / posting / likes require
CSRF token — not yet implementedbili_jct - 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