Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Expert skill for building and extending JKVideo, a React Native Bilibili-like client with DASH playback, danmaku, WBI signing, and live streaming
.claude/skills/jkvideo-bilibili-react-native/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-21 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
| case-15 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-03 | ✗→✓ | ▲ Improved | — | — |
> 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.
bashgit 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.
bashnpm install npx expo run:android # Android npx expo run:ios # iOS (macOS + Xcode required)
bashnpm install npx expo start --web # In a separate terminal: node scripts/proxy.js # Starts proxy on port 3001 to bypass Bilibili referer restrictions
Download from Releases — enable "Install from unknown sources" in Android settings.
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| 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) |
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<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; }
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 }
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) => ` <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; }
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<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 })} /> ); }
typescript// 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> ); }
typescript// 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; }
typescript// 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 }); }, }));
typescript// 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 }; }
typescript// 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'; }
typescript// 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();
Bilibili images block direct loading in browsers via Referer header. Use the bundled proxy:
javascript// 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 }
| Code | Quality | |------|---------| | 16 | 360P | | 32 | 480P | | 64 | 720P | | 80 | 1080P | | 112 | 1080P+ (大会员) | | 116 | 1080P60 (大会员) | | 120 | 4K (大会员) |
typescript// 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; }
typescript// app/history.tsx — automatically becomes /history route import { Stack } from 'expo-router'; export default function HistoryScreen() { return ( <> <Stack.Screen options={{ title: '历史记录' }} /> {/* screen content */} </> ); }
typescript// 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), } ) );
| 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 |
bili_jct CSRF token — not yet implemented| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-21 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +41 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.