diff --git a/app/index.tsx b/app/index.tsx index a2730f1..fa8e154 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -45,8 +45,6 @@ export default function HomeScreen() { const [visibleBigKey, setVisibleBigKey] = useState(null); const rows = useMemo(() => toListRows(videos), [videos]); - // useRef-wrapped to satisfy FlatList's requirement that onViewableItemsChanged never changes identity after mount - // const onViewableItemsChangedRef = useRef( ({ viewableItems }: { viewableItems: ViewToken[] }) => { const bigRow = viewableItems.find( @@ -58,11 +56,18 @@ export default function HomeScreen() { const scrollY = useRef(new Animated.Value(0)).current; - + // 阻尼限制 + const diffClamp = Animated.diffClamp(scrollY, 0, HEADER_H); const headerTranslate = scrollY.interpolate({ - inputRange: [0, NAV_H], - outputRange: [0, -NAV_H], + inputRange: [0, HEADER_H], + outputRange: [0, -HEADER_H], + extrapolate: "clamp", + }); + + const headerOpacity = scrollY.interpolate({ + inputRange: [0, HEADER_H * 0.2], + outputRange: [1, 0], extrapolate: "clamp", }); @@ -78,37 +83,40 @@ export default function HomeScreen() { [], ); - const renderItem = useCallback(({ item: row }: { item: ListRow }) => { - if (row.type === "big") { - return ( - router.push(`/video/${row.item.bvid}` as any)} - /> - ); - } - // Normal pair row - const right = row.right; - return ( - - - router.push(`/video/${row.left.bvid}` as any)} + const renderItem = useCallback( + ({ item: row }: { item: ListRow }) => { + if (row.type === "big") { + return ( + router.push(`/video/${row.item.bvid}` as any)} /> - - {right && ( - + ); + } + // Normal pair row + const right = row.right; + return ( + + router.push(`/video/${right.bvid}` as any)} + item={row.left} + onPress={() => router.push(`/video/${row.left.bvid}` as any)} /> - )} - - ); - }, []); + {right && ( + + router.push(`/video/${right.bvid}` as any)} + /> + + )} + + ); + }, + [visibleBigKey], + ); return ( @@ -155,12 +163,15 @@ export default function HomeScreen() { }, ]} > - - 哔哩哔哩 + - - - (isLoggedIn ? logout() : setShowLogin(true))} @@ -175,8 +186,12 @@ export default function HomeScreen() { /> )} + + + - + 哔哩哔哩 + 热门 @@ -241,12 +256,12 @@ const styles = StyleSheet.create({ tabActive: { fontSize: 15, fontWeight: "700", color: "#00AEEC" }, tabUnderline: { position: "absolute", - bottom: 0, - left: 16, + bottom: 4, + left: 20, width: 24, - height: 2, + height: 3, backgroundColor: "#00AEEC", - borderRadius: 1, + borderRadius: 4, }, row: { flexDirection: "row", diff --git a/components/BigVideoCard.tsx b/components/BigVideoCard.tsx index 0e820cc..a7aa6fe 100644 --- a/components/BigVideoCard.tsx +++ b/components/BigVideoCard.tsx @@ -1,21 +1,26 @@ // components/BigVideoCard.tsx -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from "react"; import { - View, Text, Image, TouchableOpacity, StyleSheet, - useWindowDimensions, 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'; + View, + Text, + Image, + TouchableOpacity, + StyleSheet, + useWindowDimensions, + 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"; 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', + 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 { @@ -69,10 +74,12 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { if (!cancelled) setVideoUrl(playData.durl?.[0]?.url); } } catch (e) { - console.warn('BigVideoCard: failed to load play URL', e); + console.warn("BigVideoCard: failed to load play URL", e); } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; // videoUrl intentionally excluded — re-fetch guard prevents redundant fetches after URL is set }, [isVisible, item.bvid]); @@ -82,7 +89,11 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { setPaused(!isVisible); if (!isVisible) { // Restore thumbnail when leaving viewport - Animated.timing(thumbOpacity, { toValue: 1, duration: 150, useNativeDriver: true }).start(); + Animated.timing(thumbOpacity, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }).start(); } }, [isVisible, videoUrl]); @@ -99,9 +110,11 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { return ( {/* Media area */} - + {/* Thumbnail */} - + + + + + {formatCount(item.stat?.view ?? 0)} + + + {/* Duration badge on thumbnail */} - {formatDuration(item.duration)} + + {formatDuration(item.duration)} + {/* Video player — only mounted when URL is available */} @@ -119,7 +141,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) { ); @@ -150,24 +173,36 @@ const styles = StyleSheet.create({ card: { marginHorizontal: 4, marginBottom: 6, - backgroundColor: '#fff', + backgroundColor: "#fff", borderRadius: 6, - overflow: 'hidden', + overflow: "hidden", }, durationBadge: { - position: 'absolute', + position: "absolute", bottom: 4, right: 4, - backgroundColor: 'rgba(0,0,0,0.6)', + backgroundColor: "rgba(0,0,0,0.6)", borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1, zIndex: 2, }, - durationText: { color: '#fff', fontSize: 10 }, + 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 }, + title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 }, + meta: { + position: "absolute", + bottom: 4, + left: 4, + paddingHorizontal: 4, + borderRadius: 5, + backgroundColor: "rgba(0,0,0,0.6)", + flexDirection: "row", + alignItems: "center", + paddingVertical: 0, + gap: 2, + zIndex: 2, + }, + metaText: { fontSize: 10, color: "#fff" }, + owner: { fontSize: 11, color: "#999", marginTop: 2 }, }); diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 4f9696f..d6cd8ff 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { File, Directory, Paths } from "expo-file-system"; +import { formatDuration } from "../utils/format"; import { View, StyleSheet, @@ -20,7 +21,7 @@ import type { DanmakuItem, } from "../services/types"; import { buildDashMpdUri } from "../utils/dash"; -import { getHeatmap, getVideoShot } from "../services/bilibili"; +import { getVideoShot } from "../services/bilibili"; import DanmakuOverlay from "./DanmakuOverlay"; const BAR_H = 3; @@ -28,9 +29,6 @@ const BAR_H = 3; const BALL = 12; // 活跃状态下的拖动球增大尺寸,提升触控体验 const BALL_ACTIVE = 16; -// 进度条分段数,越大热力图越精细但性能越差 -const SEGMENTS = 100; -// 热力图颜色从蓝(冷)到红(热) const HIDE_DELAY = 3000; const HEADERS = { @@ -43,47 +41,6 @@ function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } -function heatColor(v: number): string { - if (v < 0.5) { - const t = v * 2; - return `rgb(${Math.round(t * 255)},174,236)`; - } - const t = (v - 0.5) * 2; - return `rgb(251,${Math.round((1 - t) * 114)},${Math.round((1 - t) * 153)})`; -} - -function decodeFloats(base64: string): number[] { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - const view = new DataView(bytes.buffer); - const floats: number[] = []; - let i = 0; - while (i < bytes.length) { - const tag = bytes[i++]; - const wt = tag & 0x7; - if (wt === 5) { - floats.push(view.getFloat32(i, true)); - i += 4; - } else if (wt === 0) { - while (i < bytes.length && bytes[i++] & 0x80); - } else if (wt === 1) { - i += 8; - } else if (wt === 2) { - let len = 0, - shift = 0; - do { - const b = bytes[i++]; - len |= (b & 0x7f) << shift; - shift += 7; - if (!(b & 0x80)) break; - } while (true); - i += len; - } else break; - } - return floats; -} - function decodePvBuffer(buffer: ArrayBuffer): number[] { const bytes = new Uint8Array(buffer); const view = new DataView(buffer); @@ -186,23 +143,6 @@ function findFrameIdx(timestamps: number[], seekTime: number): number { return lo; } -function downsample(data: number[], n: number): number[] { - if (!data.length) return Array(n).fill(0); - const out = Array.from( - { length: n }, - (_, i) => data[Math.floor((i / n) * data.length)], - ); - const max = Math.max(...out); - return max ? out.map((v) => v / max) : out; -} - -function formatTime(s: number): string { - const m = Math.floor(s / 60); - return `${m}:${Math.floor(s % 60) - .toString() - .padStart(2, "0")}`; -} - interface Props { playData: PlayUrlResponse | null; qualities: { qn: number; desc: string }[]; @@ -257,7 +197,6 @@ export function NativeVideoPlayer({ const barWidthRef = useRef(300); const trackRef = useRef(null); - const [heatSegments, setHeatSegments] = useState([]); const [shots, setShots] = useState(null); const [shotTimestamps, setShotTimestamps] = useState([]); const [showDanmaku, setShowDanmaku] = useState(true); @@ -282,37 +221,25 @@ export function NativeVideoPlayer({ } }, [playData, currentQn]); - // Heatmap + shots + // Video shots (thumbnails for seek preview) useEffect(() => { if (!bvid || !cid) return; let cancelled = false; - Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then( - ([heatmap, shotData]) => { - if (cancelled) return; - if (heatmap?.pb_data) { + getVideoShot(bvid, cid).then((shotData) => { + if (cancelled) return; + if (shotData?.image?.length) { + setShots(shotData); + if (shotData.pvdata) { try { - setHeatSegments( - downsample(decodeFloats(heatmap.pb_data), SEGMENTS), - ); + loadPvData(shotData.pvdata).then((r) => { + setShotTimestamps(r); + }); } catch { - setHeatSegments([]); + setShotTimestamps([]); } } - if (shotData?.image?.length) { - setShots(shotData); - console.log(shotData.pvdata, "pvdata"); - if (shotData.pvdata) { - try { - loadPvData(shotData.pvdata).then((r) => { - setShotTimestamps(r); - }); - } catch { - setShotTimestamps([]); - } - } - } - }, - ); + } + }); return () => { cancelled = true; }; @@ -463,7 +390,9 @@ export function NativeVideoPlayer({ }} /> - {formatTime(seekTime)} + + {formatDuration(Math.floor(seekTime))} + ); }; @@ -522,7 +451,6 @@ export function NativeVideoPlayer({ {showControls && ( <> - {/* Top bar */} - {heatSegments.length > 0 ? ( - heatSegments.map((v, i) => ( - - )) - ) : ( - - )} + - {formatTime(currentTime)} + + {formatDuration(Math.floor(currentTime))} + - {formatTime(duration)} + {formatDuration(duration)} setShowQuality(true)} diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx index 9fff80f..35f0398 100644 --- a/components/VideoCard.tsx +++ b/components/VideoCard.tsx @@ -1,11 +1,18 @@ -import React from 'react'; -import { View, Text, Image, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import type { VideoItem } from '../services/types'; -import { formatCount, formatDuration } from '../utils/format'; -import { proxyImageUrl } from '../utils/imageUrl'; +import React from "react"; +import { + View, + Text, + Image, + TouchableOpacity, + StyleSheet, + Dimensions, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import type { VideoItem } from "../services/types"; +import { formatCount, formatDuration } from "../utils/format"; +import { proxyImageUrl } from "../utils/imageUrl"; -const { width } = Dimensions.get('window'); +const { width } = Dimensions.get("window"); const CARD_WIDTH = (width - 14) / 2; interface Props { @@ -15,38 +22,84 @@ interface Props { export function VideoCard({ item, onPress }: Props) { return ( - + + + + + {formatCount(item.stat?.view ?? 0)} + + - {formatDuration(item.duration)} + + {formatDuration(item.duration)} + - {item.title} - - - {formatCount(item.stat?.view ?? 0)} - - {item.owner?.name ?? ''} + + {item.title} + + + {item.owner?.name ?? ""} + ); } const styles = StyleSheet.create({ - card: { width: CARD_WIDTH, marginBottom: 6, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' }, - thumbContainer: { position: 'relative' }, - thumb: { width: CARD_WIDTH, height: CARD_WIDTH * 0.5625 }, - durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1 }, - durationText: { color: '#fff', fontSize: 10 }, + card: { + width: CARD_WIDTH, + marginBottom: 6, + backgroundColor: "#fff", + borderRadius: 6, + overflow: "hidden", + }, + thumbContainer: { position: "relative" }, + thumb: { + width: CARD_WIDTH, + height: CARD_WIDTH * 0.5625, + backgroundColor: "#ddd", + }, + durationBadge: { + position: "absolute", + bottom: 4, + right: 4, + borderRadius: 5, + paddingHorizontal: 4, + backgroundColor: "rgba(0,0,0,0.6)", + paddingVertical: 0, + }, + durationText: { color: "#fff", fontSize: 10 }, info: { padding: 6 }, - title: { fontSize: 12, color: '#212121', lineHeight: 16, height: 32, marginBottom: 4 }, - meta: { flexDirection: 'row', alignItems: 'center', gap: 2 }, - metaText: { fontSize: 11, color: '#999' }, - owner: { fontSize: 11, color: '#999', marginTop: 2 }, + title: { + fontSize: 12, + color: "#212121", + height: 33, + marginBottom: 4, + }, + owner: { fontSize: 11, color: "#999", marginTop: 2 }, + meta: { + position: "absolute", + bottom: 4, + left: 4, + paddingHorizontal: 4, + borderRadius: 5, + backgroundColor: "rgba(0,0,0,0.6)", + flexDirection: "row", + alignItems: "center", + paddingVertical: 0, + gap: 2, + }, + metaText: { fontSize: 10, color: "#fff" }, }); diff --git a/services/bilibili.ts b/services/bilibili.ts index 2f4f5a3..f7838a9 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; import pako from 'pako'; -import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, HeatmapResponse, DanmakuItem } from './types'; +import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem } from './types'; import { signWbi } from '../utils/wbi'; import { parseDanmakuXml } from '../utils/danmaku'; @@ -123,13 +123,6 @@ export async function getComments(aid: number, pn = 1): Promise { return (res.data.data?.replies ?? []) as Comment[]; } -export async function getHeatmap(bvid: string): Promise { - try { - const res = await api.get('/pbp/data', { params: { bvid } }); - return res.data.data as HeatmapResponse; - } catch { return null; } -} - export async function getVideoShot(bvid: string, cid: number): Promise { try { const res = await api.get('/x/player/videoshot', { diff --git a/services/types.ts b/services/types.ts index 6049259..1bcdebc 100644 --- a/services/types.ts +++ b/services/types.ts @@ -91,11 +91,6 @@ export interface VideoShotData { pvdata?: string; // base64 protobuf: packed float32 timestamps (seconds) per frame } -export interface HeatmapResponse { - timestamp: number; - pb_data: string; -} - export interface DanmakuItem { time: number; // 秒(float),弹幕出现时间 mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定 diff --git a/utils/format.ts b/utils/format.ts index c5b53bc..b6642be 100644 --- a/utils/format.ts +++ b/utils/format.ts @@ -5,9 +5,19 @@ export function formatCount(n: number): string { } export function formatDuration(seconds: number): string { - const m = Math.floor(seconds / 60); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; - return `${m}:${String(s).padStart(2, '0')}`; + + const mm = String(m).padStart(2, '0'); + const ss = String(s).padStart(2, '0'); + + if (h > 0) { + const hh = String(h).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } else { + return `${mm}:${ss}`; + } } export function formatTime(ctime: number): string {