mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
docs: add big video card implementation plan
This commit is contained in:
454
docs/superpowers/plans/2026-03-11-big-video-card.md
Normal file
454
docs/superpowers/plans/2026-03-11-big-video-card.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Big Video Card Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a full-width "big card" as the last item of the homepage video list, which auto-plays the video muted (qn=16, 360P) when scrolled into view, with a 16:9 thumbnail that fades out once the video is ready.
|
||||
|
||||
**Architecture:** Transform `videos: VideoItem[]` into typed rows (`NormalRow | BigRow`) before rendering; FlatList renders rows via `numColumns={1}`; viewport detection uses FlatList's `onViewableItemsChanged`; `BigVideoCard` fetches play URL lazily and renders a `Video` component from `react-native-video` on top of the thumbnail.
|
||||
|
||||
**Tech Stack:** React Native, Expo SDK 55, react-native-video, Animated API, expo-router
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Data utilities
|
||||
|
||||
### Task 1: Create `utils/videoRows.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `utils/videoRows.ts`
|
||||
|
||||
- [ ] **Step 1: Create the file with types and transformation function**
|
||||
|
||||
```ts
|
||||
// utils/videoRows.ts
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export interface NormalRow {
|
||||
type: 'pair';
|
||||
left: VideoItem;
|
||||
right: VideoItem | null;
|
||||
}
|
||||
|
||||
export interface BigRow {
|
||||
type: 'big';
|
||||
item: VideoItem;
|
||||
}
|
||||
|
||||
export type ListRow = NormalRow | BigRow;
|
||||
|
||||
/**
|
||||
* Transform a flat VideoItem array into display rows.
|
||||
* The last item always becomes a full-width BigRow.
|
||||
* All preceding items are grouped into NormalRow pairs.
|
||||
*/
|
||||
export function toListRows(videos: VideoItem[]): ListRow[] {
|
||||
if (videos.length === 0) return [];
|
||||
if (videos.length === 1) return [{ type: 'big', item: videos[0] }];
|
||||
|
||||
const rows: ListRow[] = [];
|
||||
const body = videos.slice(0, videos.length - 1);
|
||||
|
||||
for (let i = 0; i < body.length; i += 2) {
|
||||
rows.push({
|
||||
type: 'pair',
|
||||
left: body[i],
|
||||
right: body[i + 1] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({ type: 'big', item: videos[videos.length - 1] });
|
||||
return rows;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Manual verification — open a JS REPL or add a temporary console.log**
|
||||
|
||||
In any component or the `_layout.tsx`, temporarily add:
|
||||
```ts
|
||||
import { toListRows } from '../utils/videoRows';
|
||||
const sample = [{ bvid: 'A' }, { bvid: 'B' }, { bvid: 'C' }, { bvid: 'D' }, { bvid: 'E' }] as any;
|
||||
console.log(JSON.stringify(toListRows(sample), null, 2));
|
||||
// Expected: 2 pairs + 1 big row
|
||||
```
|
||||
Remove after verifying output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add utils/videoRows.ts
|
||||
git commit -m "feat: add videoRows utility for big-card list layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: BigVideoCard component
|
||||
|
||||
### Task 2: Create `components/BigVideoCard.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `components/BigVideoCard.tsx`
|
||||
|
||||
**Overview:** Displays a full-width card with a 16:9 thumbnail. When `isVisible` becomes true, fetches the play URL at qn=16 and renders a muted `Video`. When the video is ready to display, the thumbnail fades out. When `isVisible` becomes false, the video pauses.
|
||||
|
||||
- [ ] **Step 1: Create the component skeleton**
|
||||
|
||||
```tsx
|
||||
// components/BigVideoCard.tsx
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, Image, TouchableOpacity, StyleSheet,
|
||||
Dimensions, Animated,
|
||||
} from 'react-native';
|
||||
import Video from 'react-native-video';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { buildDashMpdUri } from '../utils/dash';
|
||||
import { getPlayUrl, getVideoDetail } from '../services/bilibili';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
import { formatCount, formatDuration } from '../utils/format';
|
||||
import type { VideoItem } from '../services/types';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
const { width: SCREEN_W } = Dimensions.get('window');
|
||||
const THUMB_H = SCREEN_W * 0.5625; // 16:9
|
||||
|
||||
const HEADERS = {
|
||||
Referer: 'https://www.bilibili.com',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
isVisible: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
const [videoUrl, setVideoUrl] = useState<string | undefined>();
|
||||
const [isDash, setIsDash] = useState(false);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const thumbOpacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Fetch play URL when visible for the first time
|
||||
useEffect(() => {
|
||||
if (!isVisible || videoUrl) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// cid may be missing from feed items; fetch detail if needed
|
||||
let cid = item.cid;
|
||||
if (!cid) {
|
||||
const detail = await getVideoDetail(item.bvid);
|
||||
cid = detail.cid ?? detail.pages?.[0]?.cid;
|
||||
}
|
||||
if (!cid) return;
|
||||
|
||||
const playData = await getPlayUrl(item.bvid, cid, 16);
|
||||
|
||||
if (playData.dash) {
|
||||
setIsDash(true);
|
||||
try {
|
||||
const mpdUri = await buildDashMpdUri(playData, 16);
|
||||
setVideoUrl(mpdUri);
|
||||
} catch {
|
||||
setVideoUrl(playData.dash.video[0]?.baseUrl);
|
||||
}
|
||||
} else {
|
||||
setVideoUrl(playData.durl?.[0]?.url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('BigVideoCard: failed to load play URL', e);
|
||||
}
|
||||
})();
|
||||
}, [isVisible]);
|
||||
|
||||
// Pause/resume when visibility changes
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
setPaused(!isVisible);
|
||||
if (!isVisible) {
|
||||
// Restore thumbnail when leaving viewport
|
||||
Animated.timing(thumbOpacity, { toValue: 1, duration: 150, useNativeDriver: true }).start();
|
||||
}
|
||||
}, [isVisible, videoUrl]);
|
||||
|
||||
const handleVideoReady = () => {
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
||||
{/* Media area */}
|
||||
<View style={styles.mediaContainer}>
|
||||
{/* Thumbnail */}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.pic) }}
|
||||
style={styles.thumb}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Duration badge on thumbnail */}
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Video player — only mounted when URL is available */}
|
||||
{videoUrl && (
|
||||
<Video
|
||||
source={
|
||||
isDash
|
||||
? { uri: videoUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: videoUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
muted
|
||||
paused={paused}
|
||||
repeat
|
||||
controls={false}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
||||
<View style={styles.meta}>
|
||||
<Ionicons name="play" size={11} color="#999" />
|
||||
<Text style={styles.metaText}>{formatCount(item.stat?.view ?? 0)}</Text>
|
||||
</View>
|
||||
<Text style={styles.owner} numberOfLines={1}>{item.owner?.name ?? ''}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginHorizontal: 4,
|
||||
marginBottom: 6,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mediaContainer: {
|
||||
width: SCREEN_W - 8,
|
||||
height: THUMB_H,
|
||||
},
|
||||
thumb: {
|
||||
width: SCREEN_W - 8,
|
||||
height: THUMB_H,
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
zIndex: 2,
|
||||
},
|
||||
durationText: { color: '#fff', fontSize: 10 },
|
||||
info: { padding: 8 },
|
||||
title: { fontSize: 14, color: '#212121', lineHeight: 18, marginBottom: 4 },
|
||||
meta: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
||||
metaText: { fontSize: 11, color: '#999' },
|
||||
owner: { fontSize: 11, color: '#999', marginTop: 2 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the component builds (no TypeScript errors)**
|
||||
|
||||
```bash
|
||||
cd C:\claude-code-studly\reactBilibiliApp
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no errors related to `BigVideoCard.tsx`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add components/BigVideoCard.tsx
|
||||
git commit -m "feat: add BigVideoCard with muted autoplay at qn=16"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: FlatList integration
|
||||
|
||||
### Task 3: Refactor `app/index.tsx` to use row-based layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/index.tsx`
|
||||
|
||||
**Changes:**
|
||||
- Add `useMemo` to existing React import; add `ViewToken` to react-native import
|
||||
- Import `toListRows`, `ListRow`, `BigRow` from `utils/videoRows`
|
||||
- Import `BigVideoCard` from `components/BigVideoCard`
|
||||
- Derive `rows` from `videos` via `useMemo`
|
||||
- Remove `numColumns={2}` and `columnWrapperStyle` prop (keep `styles.row` — it's used in pair renderItem)
|
||||
- Add `VIEWABILITY_CONFIG` constant (module-level)
|
||||
- Add `onViewableItemsChangedRef` (useRef-wrapped)
|
||||
- Add `visibleBigKey` state
|
||||
- Update `renderItem` to branch on row type
|
||||
- Update `keyExtractor` for row keys
|
||||
|
||||
- [ ] **Step 1: Update imports in `app/index.tsx`**
|
||||
|
||||
1a. In the existing `import React, { useEffect, useState, useRef } from "react"` line, add `useMemo`:
|
||||
```ts
|
||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
||||
```
|
||||
|
||||
1b. In the existing `import { ... } from "react-native"` block, add `ViewToken`:
|
||||
```ts
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
RefreshControl,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
```
|
||||
|
||||
1c. Add these new imports after the existing import block (use double quotes to match the file style):
|
||||
```ts
|
||||
import { toListRows, type ListRow, type BigRow } from "../utils/videoRows";
|
||||
import { BigVideoCard } from "../components/BigVideoCard";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add module-level viewability config (outside the component)**
|
||||
|
||||
Add after the `NAV_H` constants:
|
||||
```ts
|
||||
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add state and derived data inside `HomeScreen`**
|
||||
|
||||
Add inside `HomeScreen`, after the existing `useRef` lines. **Order matters:** declare state first, then memo, then the ref (the ref's closure captures `setVisibleBigKey`):
|
||||
```ts
|
||||
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
||||
const rows = useMemo(() => toListRows(videos), [videos]);
|
||||
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
const bigRow = viewableItems.find(
|
||||
(v) => v.item && (v.item as ListRow).type === 'big',
|
||||
);
|
||||
setVisibleBigKey(
|
||||
bigRow ? (bigRow.item as BigRow).item.bvid : null,
|
||||
);
|
||||
},
|
||||
).current;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `renderItem` with a row-aware version**
|
||||
|
||||
Replace the existing `renderItem` function:
|
||||
```ts
|
||||
const renderItem = ({ item: row }: { item: ListRow }) => {
|
||||
if (row.type === 'big') {
|
||||
return (
|
||||
<BigVideoCard
|
||||
item={row.item}
|
||||
isVisible={visibleBigKey === row.item.bvid}
|
||||
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Normal pair row
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<VideoCard
|
||||
item={row.left}
|
||||
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{row.right && (
|
||||
<View style={styles.rightCol}>
|
||||
<VideoCard
|
||||
item={row.right}
|
||||
onPress={() => router.push(`/video/${row.right!.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `styles.row` in the StyleSheet**
|
||||
|
||||
`styles.row` was previously used as `columnWrapperStyle` — FlatList automatically applies `flexDirection: 'row'` to column wrappers. Now that it becomes an explicit `<View style={styles.row}>`, `flexDirection: 'row'` must be added explicitly or the pair columns will stack vertically.
|
||||
|
||||
Find the `row` entry in `StyleSheet.create({...})` and update it:
|
||||
```ts
|
||||
row: { flexDirection: 'row', paddingHorizontal: 1, justifyContent: "flex-start" },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update the FlatList props**
|
||||
|
||||
In the `Animated.FlatList`, make these changes:
|
||||
|
||||
1. Change `data={videos}` → `data={rows}`
|
||||
2. Change `keyExtractor` to:
|
||||
```ts
|
||||
keyExtractor={(row: any, index) =>
|
||||
row.type === 'big'
|
||||
? `big-${row.item.bvid}`
|
||||
: `pair-${row.left.bvid}-${row.right?.bvid ?? 'empty'}-${index}`
|
||||
}
|
||||
```
|
||||
3. Remove `numColumns={2}`
|
||||
4. Remove the `columnWrapperStyle={styles.row}` **prop** from FlatList. Do NOT delete the `styles.row` StyleSheet entry — it is still referenced in the pair renderItem's `<View style={styles.row}>` (and now has `flexDirection: 'row'` from Step 5).
|
||||
5. Add after `onEndReachedThreshold`:
|
||||
```ts
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
onViewableItemsChanged={onViewableItemsChangedRef}
|
||||
```
|
||||
|
||||
Note: `react-native-video` is already installed in this project (confirmed by `components/NativeVideoPlayer.tsx` which imports `Video` from it). No additional installation needed.
|
||||
|
||||
- [ ] **Step 7: TypeScript check**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 8: Run the app and verify visually**
|
||||
|
||||
```bash
|
||||
expo start --port 8082
|
||||
```
|
||||
|
||||
Check:
|
||||
1. Homepage renders two-column layout for normal cards
|
||||
2. Last card is full-width with 16:9 thumbnail
|
||||
3. Scrolling to the last card starts muted video after a brief delay
|
||||
4. Thumbnail fades out when video is ready
|
||||
5. Scrolling away from the big card pauses the video
|
||||
6. After loading more items, the entire row list is recomputed: the previously-last item now appears in a normal pair row, and the new last item appears as the big card at the bottom
|
||||
7. When the number of normal (non-big) items is odd, the last pair row shows a single card on the left with an empty right slot
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add app/index.tsx
|
||||
git commit -m "feat: refactor homepage FlatList for big-card layout with viewport autoplay"
|
||||
```
|
||||
Reference in New Issue
Block a user