From cf20b016ff6c5e0b0684ef7f48b4ad5277ae63f9 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 10 Mar 2026 19:04:18 +0800 Subject: [PATCH] 1 --- .claude/commands/add-doc.md | 3 + app/_layout.tsx | 31 +-- app/index.tsx | 140 +++++++--- app/video/[bvid].tsx | 27 +- app/video/_layout.tsx | 8 +- components/LoginModal.tsx | 5 +- components/MiniPlayer.tsx | 90 ++++++ components/NativeVideoPlayer.tsx | 158 +++++++++-- components/VideoPlayer.tsx | 60 +++- dev-proxy.js | 60 ++++ hooks/useVideoDetail.ts | 42 ++- package-lock.json | 462 +++++++++++++++++++++++++++++++ package.json | 4 +- services/bilibili.ts | 67 +++-- services/types.ts | 8 +- store/authStore.ts | 19 +- store/videoStore.ts | 19 ++ utils/buildMpd.ts | 26 ++ 18 files changed, 1106 insertions(+), 123 deletions(-) create mode 100644 .claude/commands/add-doc.md create mode 100644 components/MiniPlayer.tsx create mode 100644 dev-proxy.js create mode 100644 store/videoStore.ts create mode 100644 utils/buildMpd.ts diff --git a/.claude/commands/add-doc.md b/.claude/commands/add-doc.md new file mode 100644 index 0000000..e41cc9e --- /dev/null +++ b/.claude/commands/add-doc.md @@ -0,0 +1,3 @@ +给代码加注释 $FILE_NAME +1.每一行代码上方加上注释 +2.注释内容需要简洁,使用中文 \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index 69ee5c2..47ddb9a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,9 +1,10 @@ -import { Tabs } from 'expo-router'; -import { Ionicons } from '@expo/vector-icons'; +import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { View } from 'react-native'; import { useEffect } from 'react'; import { useAuthStore } from '../store/authStore'; +import { MiniPlayer } from '../components/MiniPlayer'; export default function RootLayout() { const restore = useAuthStore(s => s.restore); @@ -15,28 +16,10 @@ export default function RootLayout() { return ( - - ( - - ), - }} - /> - - + + + + ); } diff --git a/app/index.tsx b/app/index.tsx index 7d11dad..c41bd13 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { - View, FlatList, StyleSheet, - Text, TouchableOpacity, ActivityIndicator, Dimensions + View, StyleSheet, + Text, TouchableOpacity, ActivityIndicator, Animated, Image, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { VideoCard } from '../components/VideoCard'; @@ -12,11 +12,26 @@ import { useVideoList } from '../hooks/useVideoList'; import { useAuthStore } from '../store/authStore'; import type { VideoItem } from '../services/types'; +const HEADER_H = 44; +const TAB_H = 38; +const NAV_H = HEADER_H + TAB_H; + export default function HomeScreen() { const router = useRouter(); const { videos, loading, refreshing, load, refresh } = useVideoList(); - const { isLoggedIn, logout } = useAuthStore(); + const { isLoggedIn, face, logout } = useAuthStore(); const [showLogin, setShowLogin] = useState(false); + const insets = useSafeAreaInsets(); + + const scrollY = useRef(new Animated.Value(0)).current; + const clampedScroll = useRef( + Animated.diffClamp(scrollY, 0, NAV_H) + ).current; + const headerTranslate = clampedScroll.interpolate({ + inputRange: [0, NAV_H], + outputRange: [0, -NAV_H], + extrapolate: 'clamp', + }); useEffect(() => { load(); }, []); @@ -30,41 +45,59 @@ export default function HomeScreen() { ); return ( - - - 哔哩哔哩 - - - - - isLoggedIn ? logout() : setShowLogin(true)} - > - - - - - - - 热门 - - - - + `${item.bvid}-${index}`} numColumns={2} columnWrapperStyle={styles.row} - contentContainerStyle={styles.list} + contentContainerStyle={{ paddingTop: insets.top + NAV_H + 8, paddingBottom: insets.bottom + 16 }} renderItem={renderItem} onRefresh={refresh} refreshing={refreshing} onEndReached={() => load()} onEndReachedThreshold={0.5} ListFooterComponent={loading ? : null} + onScroll={Animated.event( + [{ nativeEvent: { contentOffset: { y: scrollY } } }], + { useNativeDriver: true } + )} + scrollEventThrottle={16} /> + {/* 绝对定位导航栏:paddingTop 手动适配刘海/状态栏 */} + + + 哔哩哔哩 + + + + + isLoggedIn ? logout() : setShowLogin(true)} + > + {isLoggedIn && face ? ( + + ) : ( + + )} + + + + + + 热门 + + + + setShowLogin(false)} /> ); @@ -72,15 +105,54 @@ export default function HomeScreen() { const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: '#f4f4f4' }, - header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' }, + listContainer: { flex: 1 }, + navBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + backgroundColor: '#fff', + overflow: 'hidden', + // 安卓投影 + elevation: 2, + // iOS 投影 + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + }, + header: { + height: HEADER_H, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#eee', + }, logo: { fontSize: 20, fontWeight: '800', color: '#00AEEC', letterSpacing: -0.5 }, headerRight: { flexDirection: 'row', gap: 8 }, - headerBtn: { padding: 4 }, - tabRow: { backgroundColor: '#fff', paddingHorizontal: 16, paddingBottom: 0, flexDirection: 'row', alignItems: 'center', position: 'relative' }, - tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC', paddingVertical: 10 }, - tabUnderline: { position: 'absolute', bottom: 0, left: 16, width: 24, height: 2, backgroundColor: '#00AEEC', borderRadius: 1 }, + headerBtn: { padding: 6 }, + userAvatar: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#eee' }, + tabRow: { + height: TAB_H, + backgroundColor: '#fff', + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + }, + tabActive: { fontSize: 15, fontWeight: '700', color: '#00AEEC' }, + tabUnderline: { + position: 'absolute', + bottom: 0, + left: 16, + width: 24, + height: 2, + backgroundColor: '#00AEEC', + borderRadius: 1, + }, row: { paddingHorizontal: 8 }, - list: { paddingTop: 8, paddingBottom: 80 }, leftCol: { marginLeft: 4, marginRight: 2 }, rightCol: { marginLeft: 2, marginRight: 4 }, footer: { marginVertical: 16 }, diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index ee1b37c..91f4f8a 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -10,6 +10,7 @@ import { VideoPlayer } from '../../components/VideoPlayer'; import { CommentItem } from '../../components/CommentItem'; import { useVideoDetail } from '../../hooks/useVideoDetail'; import { useComments } from '../../hooks/useComments'; +import { useVideoStore } from '../../store/videoStore'; import { formatCount } from '../../utils/format'; type Tab = 'intro' | 'comments'; @@ -17,14 +18,26 @@ type Tab = 'intro' | 'comments'; export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); const router = useRouter(); - const { video, streamUrl, loading: videoLoading } = useVideoDetail(bvid as string); + const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid as string); const { comments, loading: cmtLoading, load: loadComments } = useComments(video?.aid ?? 0); const [tab, setTab] = useState('comments'); + const { setVideo, clearVideo } = useVideoStore(); + + useEffect(() => { + clearVideo(); + }, [bvid]); useEffect(() => { if (video?.aid) loadComments(); }, [video?.aid]); + function handleMiniPlayer() { + if (video) { + setVideo(bvid as string, video.title, video.pic); + router.back(); + } + } + return ( @@ -32,9 +45,18 @@ export default function VideoDetailScreen() { {video?.title ?? '视频详情'} + + + - + {videoLoading ? ( @@ -111,6 +133,7 @@ const styles = StyleSheet.create({ topBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' }, backBtn: { padding: 4 }, topTitle: { flex: 1, fontSize: 15, fontWeight: '600', marginLeft: 4, color: '#212121' }, + miniBtn: { padding: 4 }, scroll: { flex: 1 }, loader: { marginVertical: 30 }, titleSection: { padding: 14 }, diff --git a/app/video/_layout.tsx b/app/video/_layout.tsx index f3b4486..201f2af 100644 --- a/app/video/_layout.tsx +++ b/app/video/_layout.tsx @@ -1,9 +1,5 @@ -import { Stack } from 'expo-router'; +import { Slot } from 'expo-router'; export default function VideoLayout() { - return ( - - - - ); + return ; } diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 6f2bdd7..69b21c6 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; import { Modal, View, Text, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native'; -import { generateQRCode, pollQRCode } from '../services/bilibili'; +import { generateQRCode, pollQRCode, getUserInfo } from '../services/bilibili'; import { useAuthStore } from '../store/authStore'; interface Props { @@ -14,6 +14,7 @@ export function LoginModal({ visible, onClose }: Props) { const [status, setStatus] = useState<'loading' | 'waiting' | 'scanned' | 'done' | 'error'>('loading'); const pollRef = useRef | null>(null); const login = useAuthStore(s => s.login); + const setProfile = useAuthStore(s => s.setProfile); useEffect(() => { if (!visible) return; @@ -39,6 +40,8 @@ export function LoginModal({ visible, onClose }: Props) { clearInterval(pollRef.current!); await login(result.cookie, '', ''); setStatus('done'); + // 登录后异步拉取用户头像和昵称 + getUserInfo().then(info => setProfile(info.face, info.uname, String(info.mid))).catch(() => {}); onClose(); } }, 2000); diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx new file mode 100644 index 0000000..38510d9 --- /dev/null +++ b/components/MiniPlayer.tsx @@ -0,0 +1,90 @@ +import React, { useRef } from 'react'; +import { + View, Text, Image, StyleSheet, TouchableOpacity, + Animated, PanResponder, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useVideoStore } from '../store/videoStore'; + +export function MiniPlayer() { + const { isActive, bvid, title, cover, clearVideo } = useVideoStore(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const pan = useRef(new Animated.ValueXY()).current; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }), + onPanResponderRelease: () => { + pan.flattenOffset(); + }, + onPanResponderGrant: () => { + pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); + pan.setValue({ x: 0, y: 0 }); + }, + }) + ).current; + + if (!isActive) return null; + + const bottomOffset = insets.bottom + 16; + + return ( + + router.push(`/video/${bvid}` as any)} + activeOpacity={0.85} + > + + {title} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + right: 12, + width: 160, + height: 90, + borderRadius: 8, + backgroundColor: '#1a1a1a', + overflow: 'hidden', + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + }, + main: { flex: 1 }, + cover: { width: '100%', height: 64, backgroundColor: '#333' }, + title: { + color: '#fff', + fontSize: 11, + paddingHorizontal: 6, + paddingVertical: 4, + lineHeight: 14, + }, + closeBtn: { + position: 'absolute', + top: 4, + right: 4, + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: 'rgba(0,0,0,0.6)', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 3931be7..811988b 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -1,38 +1,87 @@ -import React from 'react'; -import { View, StyleSheet, Dimensions } from 'react-native'; +import React, { useState } from 'react'; +import { + View, StyleSheet, Dimensions, TouchableOpacity, + Text, Modal, FlatList, +} from 'react-native'; import { WebView } from 'react-native-webview'; +import { Ionicons } from '@expo/vector-icons'; +import { buildMpd } from '../utils/buildMpd'; +import type { PlayUrlResponse } from '../services/types'; const { width } = Dimensions.get('window'); const VIDEO_HEIGHT = width * 0.5625; interface Props { - uri: string; + playData: PlayUrlResponse | null; + qualities: { qn: number; desc: string }[]; + currentQn: number; + onQualityChange: (qn: number) => void; + onFullscreen: () => void; + onMiniPlayer?: () => void; + style?: object; } -const buildHtml = (uri: string) => ` - +function buildMp4Html(url: string): string { + return ` - + + + +`; +} + +function buildDashHtml(mpdStr: string): string { + const mpdBase64 = `data:application/dash+xml;base64,${btoa(unescape(encodeURIComponent(mpdStr)))}`; + return ` + + + + + + + + - -`; +`; +} + +function getHtml(playData: PlayUrlResponse | null): string { + if (!playData) return ''; + if (playData.dash) { + const v = playData.dash.video[0]; + const a = playData.dash.audio[0]; + if (v && a) { + const mpd = buildMpd(v.baseUrl, v.codecs, v.bandwidth, a.baseUrl, a.codecs, a.bandwidth); + return buildDashHtml(mpd); + } + } + const url = playData.durl?.[0]?.url; + if (url) return buildMp4Html(url); + return ''; +} + +export function NativeVideoPlayer({ + playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style, +}: Props) { + const [showQuality, setShowQuality] = useState(false); + const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? (currentQn ? String(currentQn) : 'HD'); + const html = getHtml(playData); -export function NativeVideoPlayer({ uri }: Props) { return ( - + + + + setShowQuality(true)}> + {currentDesc} + + + + + {onMiniPlayer && ( + + + + )} + + + + setShowQuality(false)}> + + 选择清晰度 + {qualities.map(q => ( + { + setShowQuality(false); + onQualityChange(q.qn); + }} + > + + {q.desc} + + {q.qn === currentQn && } + + ))} + + + ); } const styles = StyleSheet.create({ container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, - webview: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, + webview: { flex: 1, backgroundColor: '#000' }, + controls: { + position: 'absolute', + bottom: 8, + right: 8, + flexDirection: 'row', + gap: 8, + }, + ctrlBtn: { + backgroundColor: 'rgba(0,0,0,0.55)', + borderRadius: 4, + paddingHorizontal: 8, + paddingVertical: 4, + alignItems: 'center', + justifyContent: 'center', + }, + qualityText: { color: '#fff', fontSize: 12, fontWeight: '600' }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + qualityList: { + backgroundColor: '#fff', + borderRadius: 12, + paddingVertical: 8, + paddingHorizontal: 16, + minWidth: 180, + }, + qualityTitle: { fontSize: 15, fontWeight: '700', color: '#212121', paddingVertical: 10, textAlign: 'center' }, + qualityItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#eee', + }, + qualityItemText: { fontSize: 14, color: '#333' }, + qualityItemActive: { color: '#00AEEC', fontWeight: '700' }, }); diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index f1f13f0..0944ae5 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,16 +1,24 @@ -import React from 'react'; -import { View, StyleSheet, Dimensions, Text, Platform } from 'react-native'; +import React, { useState } from 'react'; +import { View, StyleSheet, Dimensions, Text, Platform, Modal, TouchableOpacity, StatusBar } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { NativeVideoPlayer } from './NativeVideoPlayer'; +import type { PlayUrlResponse } from '../services/types'; const { width } = Dimensions.get('window'); const VIDEO_HEIGHT = width * 0.5625; interface Props { - uri: string | null; + playData: PlayUrlResponse | null; + qualities: { qn: number; desc: string }[]; + currentQn: number; + onQualityChange: (qn: number) => void; + onMiniPlayer?: () => void; } -export function VideoPlayer({ uri }: Props) { - if (!uri) { +export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer }: Props) { + const [fullscreen, setFullscreen] = useState(false); + + if (!playData) { return ( 视频加载中... @@ -19,10 +27,11 @@ export function VideoPlayer({ uri }: Props) { } if (Platform.OS === 'web') { + const url = playData.durl?.[0]?.url ?? ''; return (