--- 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](https://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) ```bash 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) ```bash npm install npx expo run:android # Android npx expo run:ios # iOS (macOS + Xcode required) ``` ### Web with Image Proxy ```bash 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](https://github.com/tiajinsha/JKVideo/releases/latest) — 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. ```typescript // 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, imgKey: string, subKey: string ): Promise> { 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 ```typescript // 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: ```typescript // utils/buildDashMpd.ts export function buildDashMpdUri(dashData: BiliDashData): string { const { duration, video, audio } = dashData; const videoAdaptations = video.map((v) => ` ${escapeXml(v.baseUrl)} `).join(''); const audioAdaptations = audio.map((a) => ` ${escapeXml(a.baseUrl)} `).join(''); const mpd = ` ${videoAdaptations} ${audioAdaptations} `; // 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 ```typescript // 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(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 ( ); } return (