mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
热力图bug
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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=顶部固定
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user