From bc514c4a156d5ceedeac2e75342be2729578f088 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 11 Mar 2026 14:11:25 +0800 Subject: [PATCH] docs: add big video card implementation plan --- .../plans/2026-03-11-big-video-card.md | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-11-big-video-card.md diff --git a/docs/superpowers/plans/2026-03-11-big-video-card.md b/docs/superpowers/plans/2026-03-11-big-video-card.md new file mode 100644 index 0000000..205f4c1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-big-video-card.md @@ -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(); + 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 ( + + {/* Media area */} + + {/* Thumbnail */} + + + + + {/* Duration badge on thumbnail */} + + {formatDuration(item.duration)} + + + {/* Video player — only mounted when URL is available */} + {videoUrl && ( + + + {/* Info */} + + {item.title} + + + {formatCount(item.stat?.view ?? 0)} + + {item.owner?.name ?? ''} + + + ); +} + +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(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 ( + router.push(`/video/${row.item.bvid}` as any)} + /> + ); + } + // Normal pair row + return ( + + + router.push(`/video/${row.left.bvid}` as any)} + /> + + {row.right && ( + + router.push(`/video/${row.right!.bvid}` as any)} + /> + + )} + + ); +}; +``` + +- [ ] **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 ``, `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 `` (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" +```