热力图bug

This commit is contained in:
Developer
2026-03-12 23:57:01 +08:00
parent a55bcd46ea
commit 2e7578afde
7 changed files with 245 additions and 229 deletions

View File

@@ -45,8 +45,6 @@ export default function HomeScreen() {
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null); const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(videos), [videos]); const rows = useMemo(() => toListRows(videos), [videos]);
// useRef-wrapped to satisfy FlatList's requirement that onViewableItemsChanged never changes identity after mount
//
const onViewableItemsChangedRef = useRef( const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken[] }) => { ({ viewableItems }: { viewableItems: ViewToken[] }) => {
const bigRow = viewableItems.find( const bigRow = viewableItems.find(
@@ -58,11 +56,18 @@ export default function HomeScreen() {
const scrollY = useRef(new Animated.Value(0)).current; const scrollY = useRef(new Animated.Value(0)).current;
// 阻尼限制
const diffClamp = Animated.diffClamp(scrollY, 0, HEADER_H);
const headerTranslate = scrollY.interpolate({ const headerTranslate = scrollY.interpolate({
inputRange: [0, NAV_H], inputRange: [0, HEADER_H],
outputRange: [0, -NAV_H], outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const headerOpacity = scrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
extrapolate: "clamp", extrapolate: "clamp",
}); });
@@ -78,37 +83,40 @@ export default function HomeScreen() {
[], [],
); );
const renderItem = useCallback(({ item: row }: { item: ListRow }) => { const renderItem = useCallback(
if (row.type === "big") { ({ item: row }: { item: ListRow }) => {
return ( if (row.type === "big") {
<BigVideoCard return (
item={row.item} <BigVideoCard
isVisible={visibleBigKey === row.item.bvid} item={row.item}
onPress={() => router.push(`/video/${row.item.bvid}` as any)} isVisible={visibleBigKey === row.item.bvid}
/> onPress={() => router.push(`/video/${row.item.bvid}` as any)}
);
}
// Normal pair row
const right = row.right;
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={row.left}
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
/> />
</View> );
{right && ( }
<View style={styles.rightCol}> // Normal pair row
const right = row.right;
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard <VideoCard
item={right} item={row.left}
onPress={() => router.push(`/video/${right.bvid}` as any)} onPress={() => router.push(`/video/${row.left.bvid}` as any)}
/> />
</View> </View>
)} {right && (
</View> <View style={styles.rightCol}>
); <VideoCard
}, []); item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
)}
</View>
);
},
[visibleBigKey],
);
return ( return (
<SafeAreaView style={styles.safe} edges={["left", "right"]}> <SafeAreaView style={styles.safe} edges={["left", "right"]}>
@@ -155,12 +163,15 @@ export default function HomeScreen() {
}, },
]} ]}
> >
<View style={styles.header}> <Animated.View
<Text style={styles.logo}></Text> style={[
styles.header,
{
opacity: headerOpacity,
},
]}
>
<View style={styles.headerRight}> <View style={styles.headerRight}>
<TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.headerBtn} style={styles.headerBtn}
onPress={() => (isLoggedIn ? logout() : setShowLogin(true))} onPress={() => (isLoggedIn ? logout() : setShowLogin(true))}
@@ -175,8 +186,12 @@ export default function HomeScreen() {
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.headerBtn}>
<Ionicons name="search" size={22} color="#212121" />
</TouchableOpacity>
</View> </View>
</View> <Text style={styles.logo}></Text>
</Animated.View>
<View style={styles.tabRow}> <View style={styles.tabRow}>
<Text style={styles.tabActive}></Text> <Text style={styles.tabActive}></Text>
@@ -241,12 +256,12 @@ const styles = StyleSheet.create({
tabActive: { fontSize: 15, fontWeight: "700", color: "#00AEEC" }, tabActive: { fontSize: 15, fontWeight: "700", color: "#00AEEC" },
tabUnderline: { tabUnderline: {
position: "absolute", position: "absolute",
bottom: 0, bottom: 4,
left: 16, left: 20,
width: 24, width: 24,
height: 2, height: 3,
backgroundColor: "#00AEEC", backgroundColor: "#00AEEC",
borderRadius: 1, borderRadius: 4,
}, },
row: { row: {
flexDirection: "row", flexDirection: "row",

View File

@@ -1,21 +1,26 @@
// components/BigVideoCard.tsx // components/BigVideoCard.tsx
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from "react";
import { import {
View, Text, Image, TouchableOpacity, StyleSheet, View,
useWindowDimensions, Animated, Text,
} from 'react-native'; Image,
import Video from 'react-native-video'; TouchableOpacity,
import { Ionicons } from '@expo/vector-icons'; StyleSheet,
import { buildDashMpdUri } from '../utils/dash'; useWindowDimensions,
import { getPlayUrl, getVideoDetail } from '../services/bilibili'; Animated,
import { proxyImageUrl } from '../utils/imageUrl'; } from "react-native";
import { formatCount, formatDuration } from '../utils/format'; import Video from "react-native-video";
import type { VideoItem } from '../services/types'; 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 = { const HEADERS = {
Referer: 'https://www.bilibili.com', Referer: "https://www.bilibili.com",
'User-Agent': "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', "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 { interface Props {
@@ -69,10 +74,12 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
if (!cancelled) setVideoUrl(playData.durl?.[0]?.url); if (!cancelled) setVideoUrl(playData.durl?.[0]?.url);
} }
} catch (e) { } 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 // videoUrl intentionally excluded — re-fetch guard prevents redundant fetches after URL is set
}, [isVisible, item.bvid]); }, [isVisible, item.bvid]);
@@ -82,7 +89,11 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
setPaused(!isVisible); setPaused(!isVisible);
if (!isVisible) { if (!isVisible) {
// Restore thumbnail when leaving viewport // 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]); }, [isVisible, videoUrl]);
@@ -99,9 +110,11 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
return ( return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}> <TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
{/* Media area */} {/* Media area */}
<View style={[mediaDimensions, { position: 'relative' }]}> <View style={[mediaDimensions, { position: "relative" }]}>
{/* Thumbnail */} {/* Thumbnail */}
<Animated.View style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}> <Animated.View
style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}
>
<Image <Image
source={{ uri: proxyImageUrl(item.pic) }} source={{ uri: proxyImageUrl(item.pic) }}
style={mediaDimensions} style={mediaDimensions}
@@ -109,9 +122,18 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
/> />
</Animated.View> </Animated.View>
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />
<Text style={styles.metaText}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
{/* Duration badge on thumbnail */} {/* Duration badge on thumbnail */}
<View style={styles.durationBadge}> <View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text> <Text style={styles.durationText}>
{formatDuration(item.duration)}
</Text>
</View> </View>
{/* Video player — only mounted when URL is available */} {/* Video player — only mounted when URL is available */}
@@ -119,7 +141,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
<Video <Video
source={ source={
isDash isDash
? { uri: videoUrl, type: 'mpd', headers: HEADERS } ? { uri: videoUrl, type: "mpd", headers: HEADERS }
: { uri: videoUrl, headers: HEADERS } : { uri: videoUrl, headers: HEADERS }
} }
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
@@ -135,12 +157,13 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
{/* Info */} {/* Info */}
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text> <Text style={styles.title} numberOfLines={2}>
<View style={styles.meta}> {item.title}
<Ionicons name="play" size={11} color="#999" /> </Text>
<Text style={styles.metaText}>{formatCount(item.stat?.view ?? 0)}</Text>
</View> <Text style={styles.owner} numberOfLines={1}>
<Text style={styles.owner} numberOfLines={1}>{item.owner?.name ?? ''}</Text> {item.owner?.name ?? ""}
</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -150,24 +173,36 @@ const styles = StyleSheet.create({
card: { card: {
marginHorizontal: 4, marginHorizontal: 4,
marginBottom: 6, marginBottom: 6,
backgroundColor: '#fff', backgroundColor: "#fff",
borderRadius: 6, borderRadius: 6,
overflow: 'hidden', overflow: "hidden",
}, },
durationBadge: { durationBadge: {
position: 'absolute', position: "absolute",
bottom: 4, bottom: 4,
right: 4, right: 4,
backgroundColor: 'rgba(0,0,0,0.6)', backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: 3, borderRadius: 3,
paddingHorizontal: 4, paddingHorizontal: 4,
paddingVertical: 1, paddingVertical: 1,
zIndex: 2, zIndex: 2,
}, },
durationText: { color: '#fff', fontSize: 10 }, durationText: { color: "#fff", fontSize: 10 },
info: { padding: 8 }, info: { padding: 8 },
title: { fontSize: 14, color: '#212121', lineHeight: 18, marginBottom: 4 }, title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
meta: { flexDirection: 'row', alignItems: 'center', gap: 2 }, meta: {
metaText: { fontSize: 11, color: '#999' }, position: "absolute",
owner: { fontSize: 11, color: '#999', marginTop: 2 }, 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 },
}); });

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { File, Directory, Paths } from "expo-file-system"; import { File, Directory, Paths } from "expo-file-system";
import { formatDuration } from "../utils/format";
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -20,7 +21,7 @@ import type {
DanmakuItem, DanmakuItem,
} from "../services/types"; } from "../services/types";
import { buildDashMpdUri } from "../utils/dash"; import { buildDashMpdUri } from "../utils/dash";
import { getHeatmap, getVideoShot } from "../services/bilibili"; import { getVideoShot } from "../services/bilibili";
import DanmakuOverlay from "./DanmakuOverlay"; import DanmakuOverlay from "./DanmakuOverlay";
const BAR_H = 3; const BAR_H = 3;
@@ -28,9 +29,6 @@ const BAR_H = 3;
const BALL = 12; const BALL = 12;
// 活跃状态下的拖动球增大尺寸,提升触控体验 // 活跃状态下的拖动球增大尺寸,提升触控体验
const BALL_ACTIVE = 16; const BALL_ACTIVE = 16;
// 进度条分段数,越大热力图越精细但性能越差
const SEGMENTS = 100;
// 热力图颜色从蓝(冷)到红(热)
const HIDE_DELAY = 3000; const HIDE_DELAY = 3000;
const HEADERS = { const HEADERS = {
@@ -43,47 +41,6 @@ function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v)); 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[] { function decodePvBuffer(buffer: ArrayBuffer): number[] {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
const view = new DataView(buffer); const view = new DataView(buffer);
@@ -186,23 +143,6 @@ function findFrameIdx(timestamps: number[], seekTime: number): number {
return lo; 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 { interface Props {
playData: PlayUrlResponse | null; playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[]; qualities: { qn: number; desc: string }[];
@@ -257,7 +197,6 @@ export function NativeVideoPlayer({
const barWidthRef = useRef(300); const barWidthRef = useRef(300);
const trackRef = useRef<View>(null); const trackRef = useRef<View>(null);
const [heatSegments, setHeatSegments] = useState<number[]>([]);
const [shots, setShots] = useState<VideoShotData | null>(null); const [shots, setShots] = useState<VideoShotData | null>(null);
const [shotTimestamps, setShotTimestamps] = useState<number[]>([]); const [shotTimestamps, setShotTimestamps] = useState<number[]>([]);
const [showDanmaku, setShowDanmaku] = useState(true); const [showDanmaku, setShowDanmaku] = useState(true);
@@ -282,37 +221,25 @@ export function NativeVideoPlayer({
} }
}, [playData, currentQn]); }, [playData, currentQn]);
// Heatmap + shots // Video shots (thumbnails for seek preview)
useEffect(() => { useEffect(() => {
if (!bvid || !cid) return; if (!bvid || !cid) return;
let cancelled = false; let cancelled = false;
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then( getVideoShot(bvid, cid).then((shotData) => {
([heatmap, shotData]) => { if (cancelled) return;
if (cancelled) return; if (shotData?.image?.length) {
if (heatmap?.pb_data) { setShots(shotData);
if (shotData.pvdata) {
try { try {
setHeatSegments( loadPvData(shotData.pvdata).then((r) => {
downsample(decodeFloats(heatmap.pb_data), SEGMENTS), setShotTimestamps(r);
); });
} catch { } 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 () => { return () => {
cancelled = true; cancelled = true;
}; };
@@ -463,7 +390,9 @@ export function NativeVideoPlayer({
}} }}
/> />
</View> </View>
<Text style={styles.thumbTime}>{formatTime(seekTime)}</Text> <Text style={styles.thumbTime}>
{formatDuration(Math.floor(seekTime))}
</Text>
</View> </View>
); );
}; };
@@ -522,7 +451,6 @@ export function NativeVideoPlayer({
{showControls && ( {showControls && (
<> <>
{/* Top bar */}
<LinearGradient <LinearGradient
colors={["rgba(0,0,0,0.55)", "transparent"]} colors={["rgba(0,0,0,0.55)", "transparent"]}
style={styles.topBar} style={styles.topBar}
@@ -570,27 +498,12 @@ export function NativeVideoPlayer({
{...panResponder.panHandlers} {...panResponder.panHandlers}
> >
<View style={styles.track}> <View style={styles.track}>
{heatSegments.length > 0 ? ( <View
heatSegments.map((v, i) => ( style={[
<View styles.seg,
key={i} { flex: 1, backgroundColor: "#00AEEC" },
style={[ ]}
styles.seg, />
{
backgroundColor: heatColor(v),
width: `${100 / SEGMENTS}%` as any,
},
]}
/>
))
) : (
<View
style={[
styles.seg,
{ flex: 1, backgroundColor: "#00AEEC" },
]}
/>
)}
<View <View
style={[ style={[
styles.playedOverlay, styles.playedOverlay,
@@ -631,9 +544,11 @@ export function NativeVideoPlayer({
color="#fff" color="#fff"
/> />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.timeText}>{formatTime(currentTime)}</Text> <Text style={styles.timeText}>
{formatDuration(Math.floor(currentTime))}
</Text>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
<Text style={styles.timeText}>{formatTime(duration)}</Text> <Text style={styles.timeText}>{formatDuration(duration)}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.ctrlBtn} style={styles.ctrlBtn}
onPress={() => setShowQuality(true)} onPress={() => setShowQuality(true)}

View File

@@ -1,11 +1,18 @@
import React from 'react'; import React from "react";
import { View, Text, Image, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; import {
import { Ionicons } from '@expo/vector-icons'; View,
import type { VideoItem } from '../services/types'; Text,
import { formatCount, formatDuration } from '../utils/format'; Image,
import { proxyImageUrl } from '../utils/imageUrl'; 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; const CARD_WIDTH = (width - 14) / 2;
interface Props { interface Props {
@@ -15,38 +22,84 @@ interface Props {
export function VideoCard({ item, onPress }: Props) { export function VideoCard({ item, onPress }: Props) {
return ( return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}> <TouchableOpacity
style={styles.card}
onPress={onPress}
activeOpacity={0.85}
>
<View style={styles.thumbContainer}> <View style={styles.thumbContainer}>
<Image <Image
source={{ uri: proxyImageUrl(item.pic) }} source={{ uri: proxyImageUrl(item.pic) }}
style={styles.thumb} style={styles.thumb}
resizeMode="cover" resizeMode="cover"
/> />
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />
<Text style={styles.metaText}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
<View style={styles.durationBadge}> <View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text> <Text style={styles.durationText}>
{formatDuration(item.duration)}
</Text>
</View> </View>
</View> </View>
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text> <Text style={styles.title} numberOfLines={2}>
<View style={styles.meta}> {item.title}
<Ionicons name="play" size={11} color="#999" /> </Text>
<Text style={styles.metaText}>{formatCount(item.stat?.view ?? 0)}</Text> <Text style={styles.owner} numberOfLines={1}>
</View> {item.owner?.name ?? ""}
<Text style={styles.owner} numberOfLines={1}>{item.owner?.name ?? ''}</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { width: CARD_WIDTH, marginBottom: 6, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden' }, card: {
thumbContainer: { position: 'relative' }, width: CARD_WIDTH,
thumb: { width: CARD_WIDTH, height: CARD_WIDTH * 0.5625 }, marginBottom: 6,
durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1 }, backgroundColor: "#fff",
durationText: { color: '#fff', fontSize: 10 }, 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 }, info: { padding: 6 },
title: { fontSize: 12, color: '#212121', lineHeight: 16, height: 32, marginBottom: 4 }, title: {
meta: { flexDirection: 'row', alignItems: 'center', gap: 2 }, fontSize: 12,
metaText: { fontSize: 11, color: '#999' }, color: "#212121",
owner: { fontSize: 11, color: '#999', marginTop: 2 }, 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" },
}); });

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import pako from 'pako'; 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 { signWbi } from '../utils/wbi';
import { parseDanmakuXml } from '../utils/danmaku'; import { parseDanmakuXml } from '../utils/danmaku';
@@ -123,13 +123,6 @@ export async function getComments(aid: number, pn = 1): Promise<Comment[]> {
return (res.data.data?.replies ?? []) as Comment[]; return (res.data.data?.replies ?? []) as Comment[];
} }
export async function getHeatmap(bvid: string): Promise<HeatmapResponse | null> {
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<VideoShotData | null> { export async function getVideoShot(bvid: string, cid: number): Promise<VideoShotData | null> {
try { try {
const res = await api.get('/x/player/videoshot', { const res = await api.get('/x/player/videoshot', {

View File

@@ -91,11 +91,6 @@ export interface VideoShotData {
pvdata?: string; // base64 protobuf: packed float32 timestamps (seconds) per frame pvdata?: string; // base64 protobuf: packed float32 timestamps (seconds) per frame
} }
export interface HeatmapResponse {
timestamp: number;
pb_data: string;
}
export interface DanmakuItem { export interface DanmakuItem {
time: number; // 秒float弹幕出现时间 time: number; // 秒float弹幕出现时间
mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定 mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定

View File

@@ -5,9 +5,19 @@ export function formatCount(n: number): string {
} }
export function formatDuration(seconds: 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; 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 { export function formatTime(ctime: number): string {