5 Commits

Author SHA1 Message Date
github-actions[bot]
3592d036b1 chore: bump version to v1.0.19 [skip ci] 2026-05-12 13:35:56 +00:00
Developer
8fcf74ea51 Merge branch 'master' of https://github.com/tiajinsha/JKVideo 2026-05-12 21:35:15 +08:00
Developer
a869513a78 本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。
为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。

现有代码仅作学习参考保留,请勿将本项目用于任何商业或违法用途。

感谢所有支持过本项目的朋友。
2026-05-12 21:35:10 +08:00
Developer
6e0dc2729c 本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。
为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。

现有代码仅作学习参考保留,请勿将本项目用于任何商业或违法用途。

感谢所有支持过本项目的朋友。
2026-05-12 21:29:45 +08:00
Developer
53c67079a1 bug修改 2026-05-12 20:27:30 +08:00
35 changed files with 2443 additions and 866 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "JKVideo",
"slug": "jsvideo",
"version": "1.0.18",
"version": "1.0.19",
"scheme": "bilibili",
"orientation": "default",
"icon": "./assets/icon.png",

View File

@@ -6,6 +6,8 @@ import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
import { useDownloadStore } from '../store/downloadStore';
import { useSettingsStore } from '../store/settingsStore';
import { usePlayProgressStore } from '../store/playProgressStore';
import { initMiniExclusion } from '../store/miniExclusion';
import { useTheme } from '../utils/theme';
import { MiniPlayer } from '../components/MiniPlayer';
import { LiveMiniPlayer } from '../components/LiveMiniPlayer';
@@ -35,6 +37,8 @@ function RootLayout() {
restore();
loadDownloads();
restoreSettings();
usePlayProgressStore.getState().hydrate();
initMiniExclusion();
}, []);
if (!fontsLoaded) return null;

View File

@@ -36,11 +36,11 @@ import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard";
import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
import { useTheme } from "../utils/theme";
import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore";
import type { LiveRoom } from "../services/types";
const HEADER_H = 44;
@@ -69,8 +69,7 @@ const LIVE_AREAS = [
export default function HomeScreen() {
const router = useRouter();
const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
const { pages, loading, refreshing, load, refresh } = useVideoList();
const {
rooms,
loading: liveLoading,
@@ -85,8 +84,7 @@ export default function HomeScreen() {
const [liveAreaId, setLiveAreaId] = useState(0);
const theme = useTheme();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const rows = useMemo(() => toListRows(pages), [pages]);
const pagerRef = useRef<PagerView>(null);
const hotListRef = useRef<FlatList>(null);
@@ -97,57 +95,113 @@ export default function HomeScreen() {
const bigRow = viewableItems.find(
(v) => v.item && (v.item as ListRow).type === "big",
);
setVisibleBigKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
useVisibleBigKeyStore.getState().setKey(bigRow ? (bigRow.item as BigRow).item.bvid : null);
},
).current;
const scrollY = useRef(new Animated.Value(0)).current;
// 滚动累计阈值:方向反转后需累计滚动该距离才触发显隐切换
const SCROLL_THRESHOLD = 40;
// 显隐过渡时长
const HEADER_ANIM_MS = 100;
const headerTranslate = scrollY.interpolate({
const headerOffset = useRef(new Animated.Value(0)).current; // 0 = 显示HEADER_H = 隐藏
const liveHeaderOffset = useRef(new Animated.Value(0)).current;
const headerTranslate = headerOffset.interpolate({
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const headerOpacity = scrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
const headerOpacity = headerOffset.interpolate({
inputRange: [0, HEADER_H * 0.6, HEADER_H],
outputRange: [1, 1, 0],
extrapolate: "clamp",
});
// 直播列表也共用同一个 scrollY
const liveScrollY = useRef(new Animated.Value(0)).current;
const liveHeaderTranslate = liveScrollY.interpolate({
const liveHeaderTranslate = liveHeaderOffset.interpolate({
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const liveHeaderOpacity = liveScrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
const liveHeaderOpacity = liveHeaderOffset.interpolate({
inputRange: [0, HEADER_H * 0.6, HEADER_H],
outputRange: [1, 1, 0],
extrapolate: "clamp",
});
const hotScrollState = useRef({ lastY: 0, acc: 0, dir: 0, hidden: false }).current;
const liveScrollState = useRef({ lastY: 0, acc: 0, dir: 0, hidden: false }).current;
const animateHeader = useCallback(
(offset: Animated.Value, hide: boolean) => {
Animated.timing(offset, {
toValue: hide ? HEADER_H : 0,
duration: HEADER_ANIM_MS,
useNativeDriver: true,
}).start();
},
[],
);
const updateHeaderForScroll = useCallback(
(
y: number,
state: { lastY: number; acc: number; dir: number; hidden: boolean },
offset: Animated.Value,
) => {
// 顶部强制显示
if (y <= 0) {
if (state.hidden) {
state.hidden = false;
animateHeader(offset, false);
}
state.lastY = 0;
state.acc = 0;
state.dir = 0;
return;
}
const dy = y - state.lastY;
state.lastY = y;
if (Math.abs(dy) < 1) return;
const dir = dy > 0 ? 1 : -1; // 1=向下滚(隐藏),-1=向上滚(显示)
if (dir !== state.dir) {
state.dir = dir;
state.acc = 0;
}
state.acc += Math.abs(dy);
if (state.acc < SCROLL_THRESHOLD) return;
const shouldHide = dir === 1;
if (shouldHide !== state.hidden) {
state.hidden = shouldHide;
animateHeader(offset, shouldHide);
}
state.acc = 0;
},
[animateHeader],
);
useEffect(() => {
load();
}, []);
const onScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
}),
[],
const onScroll = useCallback(
(e: any) =>
updateHeaderForScroll(
e.nativeEvent.contentOffset.y,
hotScrollState,
headerOffset,
),
[updateHeaderForScroll],
);
const onLiveScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
useNativeDriver: true,
}),
[],
const onLiveScroll = useCallback(
(e: any) =>
updateHeaderForScroll(
e.nativeEvent.contentOffset.y,
liveScrollState,
liveHeaderOffset,
),
[updateHeaderForScroll],
);
const handleTabPress = useCallback(
@@ -195,41 +249,15 @@ export default function HomeScreen() {
[liveAreaId, liveLoad],
);
const visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey;
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
if (row.type === "big") {
return (
<BigVideoCard
item={row.item}
isVisible={visibleBigKeyRef.current === row.item.bvid}
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
/>
);
}
if (row.type === "live") {
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
isLivePulse
item={row.left}
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View>
{row.right && (
<View style={styles.rightCol}>
<LiveCard
isLivePulse
item={row.right}
onPress={() => router.push(`/live/${row.right!.roomid}` as any)}
/>
</View>
)}
</View>
);
}
const right = row.right;
return (
<View style={styles.row}>
@@ -303,11 +331,9 @@ export default function HomeScreen() {
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
keyExtractor={(row: any, index: number) =>
keyExtractor={(row: any) =>
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
}
contentContainerStyle={{
@@ -324,7 +350,6 @@ export default function HomeScreen() {
}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
extraData={visibleBigKey}
viewabilityConfig={VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChangedRef}
ListFooterComponent={
@@ -603,7 +628,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: "#f0f0f0",
},
areaTabActive: {
backgroundColor: "#00AEEC",

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "react";
import {
View,
Text,
@@ -6,26 +6,28 @@ import {
StyleSheet,
TouchableOpacity,
ActivityIndicator,
useWindowDimensions,
InteractionManager,
} from "react-native";
import { Image } from "expo-image";
import { SafeAreaView } from "react-native-safe-area-context";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { VideoPlayer } from "../../components/VideoPlayer";
import { CommentItem } from "../../components/CommentItem";
import { getDanmaku, getUploaderStat } from "../../services/bilibili";
import { DanmakuItem } from "../../services/types";
import DanmakuList from "../../components/DanmakuList";
import type { DanmakuItem, VideoItem } from "../../services/types";
import { useVideoDetail } from "../../hooks/useVideoDetail";
import { useComments } from "../../hooks/useComments";
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration } from "../../utils/format";
import { formatCount, formatDuration, formatTime } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { DownloadSheet } from "../../components/DownloadSheet";
import { VideoDetailSkeleton } from "../../components/VideoDetailSkeleton";
import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore";
type Tab = "intro" | "comments" | "danmaku";
import { VideoActionRow } from "../../components/VideoActionRow";
import { DescriptionSheet } from "../../components/DescriptionSheet";
import { EngagementSheet, type EngagementTab } from "../../components/EngagementSheet";
import { useFollow } from "../../hooks/useFollow";
export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
@@ -36,6 +38,16 @@ export default function VideoDetailScreen() {
useLiveStore.getState().clearLive();
}, []);
// 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过。
// 重要:依赖 [bvid] —— 切换合集/推荐时同步复位,让骨架屏盖住老数据残影。
const SKELETON_MIN_MS = 600;
const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false);
useEffect(() => {
setMinSkeletonElapsed(false);
const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS);
return () => clearTimeout(t);
}, [bvid]);
const {
video,
playData,
@@ -43,61 +55,74 @@ export default function VideoDetailScreen() {
qualities,
currentQn,
changeQuality,
initialTime,
} = useVideoDetail(bvid as string);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const {
comments,
loading: cmtLoading,
hasMore: cmtHasMore,
load: loadComments,
} = useComments(video?.aid ?? 0, commentSort);
const [tab, setTab] = useState<Tab>("intro");
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [showDownload, setShowDownload] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
const [descOverflows, setDescOverflows] = useState(false);
const [showDescSheet, setShowDescSheet] = useState(false);
// 评论/弹幕合并 Sheetnull=关闭,否则记录当前应该打开哪个 Tab
const [engagementTab, setEngagementTab] = useState<EngagementTab | null>(null);
const [uploaderStat, setUploaderStat] = useState<{
follower: number;
archiveCount: number;
} | null>(null);
// 切换视频时把和老视频绑定的瞬态状态全部清掉,避免新页面短暂显示老弹幕/老 UP 主统计/老开着的 Sheet
useEffect(() => {
setDanmakus([]);
setUploaderStat(null);
setCurrentTime(0);
setShowDescSheet(false);
setShowDownload(false);
setEngagementTab(null);
}, [bvid]);
const { following, loading: followLoading, toggle: toggleFollow } = useFollow(
video?.owner.mid,
);
// Sheet 顶部对齐播放器底部safe-area 顶 inset + 播放器高度(无 TopBar返回按钮悬浮在播放器上
const insets = useSafeAreaInsets();
const { width: SCREEN_W } = useWindowDimensions();
const sheetTopOffset = insets.top + SCREEN_W * 0.5625;
const {
videos: relatedVideos,
loading: relatedLoading,
load: loadRelated,
} = useRelatedVideos(bvid as string);
useEffect(() => { loadRelated(); }, []);
// 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程
// 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表
useEffect(() => {
if (video?.aid) loadComments();
}, [video?.aid, commentSort]);
const handle = InteractionManager.runAfterInteractions(() => {
loadRelated();
});
return () => handle.cancel();
}, [bvid, loadRelated]);
useEffect(() => {
if (!video?.cid) return;
getDanmaku(video.cid).then(setDanmakus).catch(() => {});
const handle = InteractionManager.runAfterInteractions(() => {
getDanmaku(video.cid!).then(setDanmakus).catch(() => {});
});
return () => handle.cancel();
}, [video?.cid]);
useEffect(() => {
if (!video?.owner?.mid) return;
const handle = InteractionManager.runAfterInteractions(() => {
getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {});
});
return () => handle.cancel();
}, [video?.owner?.mid]);
const shareUrl = bvid ? `https://www.bilibili.com/video/${bvid}` : undefined;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
{/* TopBar */}
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
{video?.title ?? "视频详情"}
</Text>
<TouchableOpacity style={styles.miniBtn} onPress={() => setShowDownload(true)}>
<Ionicons name="cloud-download-outline" size={22} color={theme.text} />
</TouchableOpacity>
</View>
<VideoPlayer
playData={playData}
qualities={qualities}
@@ -107,52 +132,69 @@ export default function VideoDetailScreen() {
cid={video?.cid}
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
/>
<DownloadSheet
visible={showDownload}
onClose={() => setShowDownload(false)}
bvid={bvid as string}
cid={video?.cid ?? 0}
title={video?.title ?? ""}
cover={video?.pic ?? ""}
qualities={qualities}
initialTime={initialTime}
onBack={() => router.back()}
coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined}
/>
{/* TabBar */}
{video && (
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}>
{(["intro", "comments", "danmaku"] as Tab[]).map((t) => {
const label =
t === "intro" ? "简介"
: t === "comments" ? `评论${video.stat?.reply ? ` ${formatCount(video.stat.reply)}` : ""}`
: `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`;
return (
<TouchableOpacity key={t} style={styles.tabItem} onPress={() => setTab(t)}>
<Text style={[styles.tabLabel, { color: theme.textSub }, tab === t && styles.tabActive]}>
{label}
</Text>
{tab === t && <View style={styles.tabUnderline} />}
</TouchableOpacity>
);
})}
</View>
)}
{/* Tab content */}
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
<>
{tab === "intro" && (
<FlatList<import("../../services/types").VideoItem>
style={styles.tabScroll}
{videoLoading || !video || !minSkeletonElapsed ? (
<VideoDetailSkeleton />
) : (
<FlatList<VideoItem>
// bvid 是 key —— 切换视频时 FlatList 整体重挂,滚动位置回到顶,避免老内容残影
key={bvid}
style={styles.scroll}
data={relatedVideos}
keyExtractor={(item) => item.bvid}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
{/* 标题 + Meta 行(点击展开简介 Sheet */}
<View style={styles.titleBlock}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{video.title}
</Text>
<TouchableOpacity
style={styles.upRow}
style={styles.metaRow}
activeOpacity={0.7}
onPress={() => setShowDescSheet(true)}
>
<Text
style={[styles.metaText, { color: theme.textSub }]}
numberOfLines={1}
>
{formatCount(video.stat?.view ?? 0)}
{video.pubdate ? ` · ${formatTime(video.pubdate)}` : ""}
{video.stat?.like ? ` · ${formatCount(video.stat.like)} 点赞` : ""}
{video.tname ? ` · ${video.tname}` : ""}
</Text>
<Text style={[styles.metaMore, { color: theme.textSub }]}></Text>
<Ionicons name="chevron-forward" size={14} color={theme.textSub} />
</TouchableOpacity>
</View>
{/* 动作按钮行 */}
<View style={[styles.actionWrap, { borderTopColor: theme.border }]}>
<VideoActionRow
stat={video.stat ?? undefined}
danmakuCount={danmakus.length || video.stat?.danmaku}
title={video.title}
shareUrl={shareUrl}
onDownload={() => setShowDownload(true)}
onComments={() => setEngagementTab("comments")}
onDanmaku={() => setEngagementTab("danmaku")}
/>
</View>
{/* UP 主行 + 关注按钮 */}
<View
style={[
styles.creatorRow,
{ borderTopColor: theme.border, borderBottomColor: theme.border },
]}
>
<TouchableOpacity
style={styles.creatorLeft}
activeOpacity={0.85}
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
>
@@ -162,55 +204,42 @@ export default function VideoDetailScreen() {
contentFit="cover"
recyclingKey={String(video.owner.mid)}
/>
<View style={styles.upInfo}>
<Text style={[styles.upName, { color: theme.text }]}>{video.owner.name}</Text>
<View style={styles.creatorInfo}>
<Text
style={[styles.creatorName, { color: theme.text }]}
numberOfLines={1}
>
{video.owner.name}
</Text>
{uploaderStat && (
<Text style={styles.upStat}>
{formatCount(uploaderStat.follower)} · {formatCount(uploaderStat.archiveCount)}
<Text
style={[styles.creatorStat, { color: theme.textSub }]}
numberOfLines={1}
>
{formatCount(uploaderStat.follower)} ·{" "}
{formatCount(uploaderStat.archiveCount)}
</Text>
)}
</View>
<View style={styles.upStats}>
<Ionicons name="play-outline" size={12} color={theme.textSub} />
<Text style={styles.upStatTxt}>{formatCount(video.stat?.view ?? 0)}</Text>
<Text style={styles.upStatDot}>·</Text>
<Ionicons name="heart-outline" size={12} color={theme.textSub} />
<Text style={styles.upStatTxt}>{formatCount(video.stat?.like ?? 0)}</Text>
</View>
</TouchableOpacity>
<View style={[styles.titleSection, { borderBottomColor: theme.border }]}>
<Text style={[styles.title, { color: theme.text }]}>{video.title}</Text>
{!!video.tname && (
<View style={styles.tnameBadge}>
<Text style={styles.tnameText}>{video.tname}</Text>
</View>
)}
{!!video.desc && (
<Text
style={[styles.subTitle, styles.descMeasure]}
onTextLayout={(e) => setDescOverflows(e.nativeEvent.lines.length > 2)}
>
{video.desc}
</Text>
)}
<Text
style={[styles.subTitle, { color: theme.textSub }]}
numberOfLines={descExpanded ? undefined : 2}
>
{video.desc || "暂无简介"}
</Text>
{descOverflows && (
<TouchableOpacity
onPress={() => setDescExpanded(e => !e)}
style={styles.expandBtn}
style={[styles.subBtn, following && styles.subBtnFollowing]}
activeOpacity={0.85}
onPress={toggleFollow}
disabled={followLoading}
>
<Ionicons name={descExpanded ? "chevron-up" : "chevron-down"} size={13} color="#00AEEC" />
<Text style={styles.expandBtnTxt}>{descExpanded ? "收起" : "展开"}</Text>
<Text
style={[
styles.subBtnTxt,
following && { color: theme.textSub },
]}
>
{following ? "已关注" : "关注"}
</Text>
</TouchableOpacity>
)}
</View>
{/* 合集 */}
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
@@ -218,14 +247,21 @@ export default function VideoDetailScreen() {
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
/>
)}
{/* 推荐视频小标题 */}
<View style={[styles.relatedHeader, { backgroundColor: theme.card }]}>
<Text style={[styles.relatedHeaderText, { color: theme.text }]}></Text>
<Text style={[styles.relatedHeaderText, { color: theme.text }]}>
</Text>
</View>
</>
}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.relatedCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
style={[
styles.relatedCard,
{ backgroundColor: theme.card, borderBottomColor: theme.border },
]}
onPress={() => router.replace(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
@@ -238,101 +274,115 @@ export default function VideoDetailScreen() {
transition={200}
/>
<View style={styles.relatedDuration}>
<Text style={styles.relatedDurationText}>{formatDuration(item.duration)}</Text>
<Text style={styles.relatedDurationText}>
{formatDuration(item.duration)}
</Text>
</View>
</View>
<View style={styles.relatedInfo}>
<Text style={[styles.relatedTitle, { color: theme.text }]} numberOfLines={2}>
<Text
style={[styles.relatedTitle, { color: theme.text }]}
numberOfLines={2}
>
{item.title}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
<Text style={styles.relatedOwner} numberOfLines={1}>{item.owner?.name ?? ""}</Text>
<Text style={styles.relatedView}>{formatCount(item.stat?.view ?? 0)} </Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={[styles.relatedOwner, { color: theme.textSub }]}
numberOfLines={1}
>
{item.owner?.name ?? ""}
</Text>
<Text style={[styles.relatedView, { color: theme.textSub }]}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
relatedLoading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
}
ListFooterComponent={
relatedLoading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
}
/>
)}
{tab === "comments" && (
<FlatList
style={styles.tabScroll}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={[styles.sortRow, { borderBottomColor: theme.border }]}>
{([2, 0] as const).map((sort) => (
<TouchableOpacity
key={sort}
style={[styles.sortBtn, commentSort === sort && styles.sortBtnActive]}
onPress={() => setCommentSort(sort)}
>
<Text style={[styles.sortBtnTxt, commentSort === sort && styles.sortBtnTxtActive]}>
{sort === 2 ? "热门" : "最新"}
</Text>
</TouchableOpacity>
))}
</View>
}
ListFooterComponent={
cmtLoading ? (
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
ListFooterComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !cmtHasMore && comments.length > 0 ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
ListEmptyComponent={!cmtLoading ? <Text style={styles.emptyTxt}></Text> : null}
/>
)}
{/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */}
<DanmakuList
<DownloadSheet
visible={showDownload}
onClose={() => setShowDownload(false)}
topOffset={sheetTopOffset}
bvid={bvid as string}
cid={video?.cid ?? 0}
title={video?.title ?? ""}
cover={video?.pic ?? ""}
qualities={qualities}
/>
<DescriptionSheet
visible={showDescSheet}
onClose={() => setShowDescSheet(false)}
topOffset={sheetTopOffset}
video={video ?? null}
/>
<EngagementSheet
visible={engagementTab !== null}
onClose={() => setEngagementTab(null)}
topOffset={sheetTopOffset}
initialTab={engagementTab ?? "comments"}
aid={video?.aid ?? 0}
replyCount={video?.stat?.reply}
danmakus={danmakus}
currentTime={currentTime}
visible={tab === "danmaku"}
onToggle={() => {}}
hideHeader={true}
style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]}
/>
</>
) : null}
</SafeAreaView>
);
}
function SeasonSection({
season,
currentBvid,
onEpisodePress,
}: {
season: NonNullable<import("../../services/types").VideoItem["ugc_season"]>;
season: NonNullable<VideoItem["ugc_season"]>;
currentBvid: string;
onEpisodePress: (bvid: string) => void;
}) {
const theme = useTheme();
const { width: screenW } = useWindowDimensions();
const episodes = season.sections?.[0]?.episodes ?? [];
const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid);
const listRef = useRef<FlatList>(null);
// 初次渲染先隐身scrollToOffset 完成后再显示,彻底消除"先 0 再跳"的可见闪烁
const [ready, setReady] = useState(currentIndex <= 0);
useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return;
const t = setTimeout(() => {
listRef.current?.scrollToIndex({ index: currentIndex, viewPosition: 0.5, animated: false });
}, 200);
return () => clearTimeout(t);
}, [currentIndex, episodes.length]);
// 计算让当前集水平居中的初始 contentOffset
const initialOffset = useMemo(() => {
if (currentIndex <= 0) return 0;
const ITEM_WIDTH = 120;
const STEP = 130; // 120 + 10 gap
const PADDING = 12;
const itemCenter = PADDING + currentIndex * STEP + ITEM_WIDTH / 2;
return Math.max(0, itemCenter - screenW / 2);
}, [currentIndex, screenW]);
const handleContentSizeChange = useCallback(() => {
if (ready) return;
if (currentIndex > 0 && initialOffset > 0) {
listRef.current?.scrollToOffset({ offset: initialOffset, animated: false });
}
requestAnimationFrame(() => setReady(true));
}, [ready, currentIndex, initialOffset]);
return (
<View style={[styles.seasonBox, { borderTopColor: theme.border, backgroundColor: theme.card }]}>
@@ -349,7 +399,10 @@ function SeasonSection({
keyExtractor={(ep) => ep.bvid}
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })}
contentOffset={{ x: initialOffset, y: 0 }}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={() => {}}
style={{ opacity: ready ? 1 : 0 }}
renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid;
return (
@@ -382,46 +435,49 @@ function SeasonSection({
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
loader: { marginVertical: 30 },
scroll: { flex: 1 },
// Title + MetaYT 风格)
titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 8 },
title: { fontSize: 16, fontWeight: "700", lineHeight: 22 },
metaRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 8,
marginTop: 6,
},
metaText: { flex: 1, fontSize: 12, lineHeight: 18 },
metaMore: { fontSize: 12, lineHeight: 18, marginLeft: 6, marginRight: 2 },
// Action row 外壳
actionWrap: { borderTopWidth: StyleSheet.hairlineWidth },
// UP 主行
creatorRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4 },
miniBtn: { padding: 4 },
loader: { marginVertical: 30 },
titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth },
title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 6 },
tnameBadge: {
alignSelf: "flex-start",
backgroundColor: "rgba(0,174,236,0.12)",
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
marginBottom: 8,
creatorLeft: { flex: 1, flexDirection: "row", alignItems: "center" },
avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 },
creatorInfo: { flex: 1 },
creatorName: { fontSize: 14, fontWeight: "600", lineHeight: 18 },
creatorStat: { fontSize: 11, marginTop: 2, lineHeight: 16 },
subBtn: {
backgroundColor: "#00AEEC",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
marginLeft: 12,
},
tnameText: { fontSize: 11, color: "#00AEEC" },
subTitle: { fontSize: 12, lineHeight: 18, marginBottom: 4 },
descMeasure: { position: "absolute", opacity: 0, left: 0, right: 0 },
expandBtn: { flexDirection: "row", alignItems: "center", gap: 3, marginBottom: 8 },
expandBtnTxt: { fontSize: 12, color: "#00AEEC" },
upRow: {
flexDirection: "row",
alignItems: "flex-start",
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 10,
},
avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10, marginTop: 1 },
upInfo: { flex: 1 },
upName: { fontSize: 13, fontWeight: "600", lineHeight: 18 },
upStat: { fontSize: 11, color: "#999", marginTop: 2, lineHeight: 16 },
upStats: { flexDirection: "row", alignItems: "center", gap: 3, marginTop: 3 },
upStatTxt: { fontSize: 11, color: "#999" },
upStatDot: { fontSize: 11, color: "#ccc" },
// 已关注:浅灰底 + 灰字textSub 由 useTheme 提供)
subBtnFollowing: { backgroundColor: "rgba(127,127,127,0.18)" },
subBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
// 合集
seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 },
seasonHeader: {
flexDirection: "row",
@@ -438,24 +494,9 @@ const styles = StyleSheet.create({
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
epNumActive: { color: "#00AEEC", fontWeight: "600" },
epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 },
tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, paddingLeft: 3 },
tabItem: { alignItems: "center", paddingVertical: 12, paddingHorizontal: 12, position: "relative" },
tabLabel: { fontSize: 13 },
tabActive: { color: "#00AEEC" },
tabUnderline: {
position: "absolute",
bottom: 0,
width: 24,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 1,
},
tabScroll: { flex: 1 },
descBox: { padding: 16 },
descText: { fontSize: 14, lineHeight: 22 },
danmakuTab: { flex: 1 },
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 8 },
// 推荐列表
relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 12 },
relatedHeaderText: { fontSize: 13, fontWeight: "600" as const },
relatedCard: {
flexDirection: "row",
@@ -485,19 +526,6 @@ const styles = StyleSheet.create({
relatedDurationText: { color: "#fff", fontSize: 10 },
relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
relatedTitle: { fontSize: 13, lineHeight: 18 },
relatedOwner: { fontSize: 12, color: "#999" },
relatedView: { fontSize: 11, color: "#bbb" },
sortRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sortBtn: { paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, backgroundColor: "#f0f0f0" },
sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },
relatedOwner: { fontSize: 12 },
relatedView: { fontSize: 11 },
});

View File

@@ -24,7 +24,9 @@ import { coverImageUrl } from "../utils/imageUrl";
import { useSettingsStore } from "../store/settingsStore";
import { useTheme } from "../utils/theme";
import { useLiveStore } from "../store/liveStore";
import { usePlayUrlCache } from "../store/playUrlCache";
import { formatCount, formatDuration } from "../utils/format";
import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore";
import type { VideoItem } from "../services/types";
const HEADERS = {
@@ -45,17 +47,16 @@ function clamp(v: number, lo: number, hi: number) {
interface Props {
item: VideoItem;
isVisible: boolean;
isScrolling?: boolean;
onPress: () => void;
}
export const BigVideoCard = React.memo(function BigVideoCard({
item,
isVisible,
isScrolling,
onPress,
}: Props) {
const isVisible = useVisibleBigKeyStore((s) => s.key) === item.bvid;
const { width: SCREEN_W } = useWindowDimensions();
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const liveActive = useLiveStore(s => s.isActive);
@@ -79,6 +80,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
const durationRef = useRef(0);
const seekingRef = useRef(false);
const [seekLabel, setSeekLabel] = useState<string | null>(null);
const lastProgressUpdate = useRef(0);
// Reset video state when the item changes
useEffect(() => {
@@ -98,6 +100,18 @@ export const BigVideoCard = React.memo(function BigVideoCard({
let cancelled = false;
(async () => {
try {
// 命中缓存5min TTL直接复用
const cached = usePlayUrlCache.getState().get(item.bvid, 16);
if (cached) {
if (cancelled) return;
if (cached.playData.dash) {
setIsDash(true);
setVideoUrl(cached.mpdUri ?? cached.playData.dash.video[0]?.baseUrl);
} else {
setVideoUrl(cached.playData.durl?.[0]?.url);
}
return;
}
let cid = item.cid;
if (!cid) {
const detail = await getVideoDetail(item.bvid);
@@ -113,13 +127,22 @@ export const BigVideoCard = React.memo(function BigVideoCard({
if (playData.dash) {
if (!cancelled) setIsDash(true);
try {
const mpdUri = await buildDashMpdUri(playData, 16);
if (!cancelled) setVideoUrl(mpdUri);
const mpdUri = await buildDashMpdUri(playData, 16, item.bvid);
if (!cancelled) {
setVideoUrl(mpdUri);
usePlayUrlCache.getState().set(item.bvid, 16, { playData, mpdUri });
}
} catch {
if (!cancelled) setVideoUrl(playData.dash.video[0]?.baseUrl);
if (!cancelled) {
setVideoUrl(playData.dash.video[0]?.baseUrl);
usePlayUrlCache.getState().set(item.bvid, 16, { playData });
}
}
} else {
if (!cancelled) setVideoUrl(playData.durl?.[0]?.url);
if (!cancelled) {
setVideoUrl(playData.durl?.[0]?.url);
usePlayUrlCache.getState().set(item.bvid, 16, { playData });
}
}
} catch (e) {
console.warn("BigVideoCard: failed to load play URL", e);
@@ -252,8 +275,14 @@ export const BigVideoCard = React.memo(function BigVideoCard({
seekableDuration: dur,
playableDuration: buf,
}) => {
if (!seekingRef.current) setCurrentTime(ct);
if (dur > 0) setDuration(dur);
currentTimeRef.current = ct;
if (dur > 0) durationRef.current = dur;
if (seekingRef.current) return;
const now = Date.now();
if (now - lastProgressUpdate.current < 450) return;
lastProgressUpdate.current = now;
setCurrentTime(ct);
if (dur > 0 && Math.abs(dur - duration) > 1) setDuration(dur);
setBuffered(buf);
}}
/>

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { View, Animated, StyleSheet, Text } from 'react-native';
import React, { useRef, useEffect, useState, useCallback, memo } from 'react';
import { View, Animated, StyleSheet } from 'react-native';
import { DanmakuItem } from '../services/types';
import { danmakuColorToCss } from '../utils/danmaku';
@@ -11,18 +11,91 @@ interface Props {
visible: boolean;
}
const LANE_COUNT = 5;
const LANE_H = 28;
// ─── 配置 ───────────────────────────────────────────────────────────────────
// 字体上限(小一点更接近 PC B 站手感)
const FONT_MAX = 16;
// 单条车道高度
const LANE_H = 22;
// 横向车道数
const LANE_COUNT = 6;
// 同屏弹幕上限
const MAX_ACTIVE = 80;
// activated 集合阈值,触达后整体清零防止内存膨胀
const ACTIVATED_LIMIT = 1000;
// 滚动恒定速度(像素 / 秒)—— 长文走得久,视觉上速度一致
const SCROLL_PX_PER_SEC = 160;
// 入/出场淡入淡出时长
const FADE_MS = 200;
// 估算文本像素宽度。中文等表意字符按 1.0x fontSizeASCII 按 0.55x。
function estimateTextWidth(text: string, fontSize: number): number {
let w = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code > 0x2e80) w += fontSize;
else w += fontSize * 0.55;
}
return w;
}
interface ActiveDanmaku {
id: string;
item: DanmakuItem;
lane: number;
fontSize: number;
tx: Animated.Value;
opacity: Animated.Value;
}
export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, screenHeight, visible }: Props) {
// ─── 单条弹幕 ──────────────────────────────────────────────────────────────
// React.memo新弹幕加入触发父级 re-render 时,旧弹幕不会重新走 JSX。
// 由于 Animated.Value 的引用稳定、其他 props 也都是值类型,自然能命中浅比较。
const DanmakuLine = memo(
function DanmakuLine({
d,
screenHeight,
}: {
d: ActiveDanmaku;
screenHeight: number;
}) {
const isScrolling = d.item.mode === 1;
const isTop = d.item.mode === 5;
return (
<Animated.Text
style={{
position: 'absolute',
top: isScrolling
? 16 + d.lane * LANE_H
: isTop
? 16
: screenHeight - 36,
left: isScrolling ? 0 : undefined,
alignSelf: !isScrolling ? 'center' : undefined,
transform: isScrolling ? [{ translateX: d.tx }] : [],
opacity: d.opacity,
color: danmakuColorToCss(d.item.color),
fontSize: d.fontSize,
fontWeight: '700',
// 描边1px 偏移 + radius 0避免模糊带来的额外栅格化
textShadowColor: 'rgba(0,0,0,0.9)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 0,
}}
>
{d.item.text}
</Animated.Text>
);
},
(prev, next) => prev.d === next.d && prev.screenHeight === next.screenHeight,
);
export default function DanmakuOverlay({
danmakus,
currentTime,
screenWidth,
screenHeight,
visible,
}: Props) {
const [activeDanmakus, setActiveDanmakus] = useState<ActiveDanmaku[]>([]);
const laneAvailAt = useRef<number[]>(new Array(LANE_COUNT).fill(0));
const activated = useRef<Set<string>>(new Set());
@@ -30,7 +103,9 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
const idCounter = useRef(0);
const mountedRef = useRef(true);
useEffect(() => {
return () => { mountedRef.current = false; };
return () => {
mountedRef.current = false;
};
}, []);
const pickLane = useCallback((): number | null => {
@@ -55,77 +130,123 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
prevTimeRef.current = currentTime;
if (didSeek) {
// Clear on seek
activated.current.clear();
laneAvailAt.current.fill(0);
setActiveDanmakus([]);
return;
}
// Find danmakus in the activation window
const window = 0.4;
const candidates = danmakus.filter(d => {
const candidates = danmakus.filter((d) => {
const key = `${d.time}_${d.text}`;
return d.time >= currentTime - window && d.time <= currentTime + window && !activated.current.has(key);
return (
d.time >= currentTime - window &&
d.time <= currentTime + window &&
!activated.current.has(key)
);
});
if (candidates.length === 0) return;
if (activated.current.size > ACTIVATED_LIMIT) {
activated.current.clear();
idCounter.current = 0;
}
const newItems: ActiveDanmaku[] = [];
if (activated.current.size > 200) activated.current.clear(); // prevent memory leak
for (const item of candidates) {
const key = `${item.time}_${item.text}`;
activated.current.add(key);
if (item.mode === 1) {
// Scrolling danmaku
const lane = pickLane();
if (lane === null) continue; // drop if all lanes full
const fontSize = Math.min(item.fontSize || 18, FONT_MAX);
const charWidth = Math.min(item.fontSize, 22) * 0.8;
const textWidth = item.text.length * charWidth;
const duration = 8000;
// Lane becomes available when tail of this danmaku clears the right edge of screen
// tail starts at screenWidth, text has width textWidth
// tail clears left edge at duration ms
// lane available when head of next can start without overlapping: when tail clears screen right = immediately for next (head starts at screenWidth)
// conservative: lane free after textWidth / (2*screenWidth) * duration ms
const laneDelay = (textWidth / (screenWidth + textWidth)) * duration;
laneAvailAt.current[lane] = Date.now() + laneDelay;
if (item.mode === 1) {
const lane = pickLane();
if (lane === null) continue;
const textWidth = estimateTextWidth(item.text, fontSize);
const totalDistance = screenWidth + textWidth + 20;
// 恒定速度duration 由文本长度推导,长文走久一点,视觉速度统一
const duration = (totalDistance / SCROLL_PX_PER_SEC) * 1000;
// 车道何时可被复用:尾巴扫过屏幕右边沿即可,与 duration 解耦
const laneFreeMs = (textWidth / SCROLL_PX_PER_SEC) * 1000;
laneAvailAt.current[lane] = Date.now() + laneFreeMs;
const tx = new Animated.Value(screenWidth);
const opacity = new Animated.Value(0);
const id = `d_${idCounter.current++}`;
newItems.push({ id, item, lane, tx, opacity: new Animated.Value(1) });
newItems.push({ id, item, lane, fontSize, tx, opacity });
// 入场淡入
Animated.timing(opacity, {
toValue: 1,
duration: FADE_MS,
useNativeDriver: true,
}).start();
// 滚动主动画(线性,视觉速度恒定)
Animated.timing(tx, {
toValue: -textWidth - 20,
duration,
useNativeDriver: true,
}).start(() => {
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id));
}).start(({ finished }) => {
if (!mountedRef.current) return;
if (!finished) {
setActiveDanmakus((prev) => prev.filter((d) => d.id !== id));
return;
}
// 滚到尽头前已经看不到了,直接移除即可(避免重叠淡出动画)
setActiveDanmakus((prev) => prev.filter((d) => d.id !== id));
});
// 临近终点淡出duration - FADE_MS 后开始
Animated.sequence([
Animated.delay(Math.max(0, duration - FADE_MS)),
Animated.timing(opacity, {
toValue: 0,
duration: FADE_MS,
useNativeDriver: true,
}),
]).start();
} else {
// Fixed danmaku (mode 4 = bottom, mode 5 = top)
const opacity = new Animated.Value(1);
// 固定位置4=底, 5=顶)
const opacity = new Animated.Value(0);
const id = `d_${idCounter.current++}`;
newItems.push({ id, item, lane: -1, tx: new Animated.Value(0), opacity });
newItems.push({
id,
item,
lane: -1,
fontSize,
tx: new Animated.Value(0),
opacity,
});
Animated.sequence([
Animated.delay(2000),
Animated.timing(opacity, { toValue: 0, duration: 500, useNativeDriver: true }),
Animated.timing(opacity, {
toValue: 1,
duration: FADE_MS,
useNativeDriver: true,
}),
Animated.delay(2200),
Animated.timing(opacity, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start(() => {
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id));
if (mountedRef.current) {
setActiveDanmakus((prev) => prev.filter((d) => d.id !== id));
}
});
}
}
if (newItems.length > 0) {
setActiveDanmakus(prev => {
setActiveDanmakus((prev) => {
const combined = [...prev, ...newItems];
// Cap at 30 simultaneous danmakus
return combined.slice(Math.max(0, combined.length - 30));
return combined.slice(Math.max(0, combined.length - MAX_ACTIVE));
});
}
}, [currentTime, visible, danmakus, pickLane, screenWidth]);
@@ -134,37 +255,9 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents="none">
{activeDanmakus.map(d => {
const fontSize = Math.min(d.item.fontSize || 25, 22);
const isScrolling = d.item.mode === 1;
const isTop = d.item.mode === 5;
return (
<Animated.Text
key={d.id}
style={{
position: 'absolute',
top: isScrolling
? 20 + d.lane * LANE_H
: isTop
? 20
: screenHeight - 48,
left: isScrolling ? 0 : undefined,
alignSelf: !isScrolling ? 'center' : undefined,
transform: isScrolling ? [{ translateX: d.tx }] : [],
opacity: d.opacity,
color: danmakuColorToCss(d.item.color),
fontSize,
fontWeight: '700',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}
>
{d.item.text}
</Animated.Text>
);
})}
{activeDanmakus.map((d) => (
<DanmakuLine key={d.id} d={d} screenHeight={screenHeight} />
))}
</View>
);
}

View File

@@ -0,0 +1,158 @@
import React from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
Animated,
StyleSheet,
ScrollView,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { VideoItem } from "../services/types";
import { useTheme } from "../utils/theme";
import { formatCount, formatTime } from "../utils/format";
import { useSheetTransition } from "../utils/useSheetTransition";
interface Props {
visible: boolean;
onClose: () => void;
/** Sheet 顶部对齐到屏幕的绝对 Y一般是播放器底部 */
topOffset: number;
video: VideoItem | null;
}
export function DescriptionSheet({ visible, onClose, topOffset, video }: Props) {
const theme = useTheme();
const { height } = useWindowDimensions();
const sheetH = Math.max(120, height - topOffset);
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
if (!rendered || !video) return null;
const stat = video.stat;
return (
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<Animated.View
style={[
styles.sheet,
{
top: topOffset,
backgroundColor: theme.sheetBg,
transform: [{ translateY: slideAnim }],
},
]}
>
<View style={[styles.header, { borderBottomColor: theme.modalBorder }]}>
<Text style={[styles.headerTitle, { color: theme.modalText }]}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<Ionicons name="close" size={22} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<Text style={[styles.title, { color: theme.modalText }]}>{video.title}</Text>
{!!video.tname && (
<View style={styles.tnameBadge}>
<Text style={styles.tnameText}>{video.tname}</Text>
</View>
)}
{!!video.pubdate && (
<Text style={[styles.pubdate, { color: theme.modalTextSub }]}>
{formatTime(video.pubdate)}
</Text>
)}
{!!stat && (
<View style={styles.statGrid}>
<StatCell label="播放" value={stat.view} theme={theme} />
<StatCell label="弹幕" value={stat.danmaku} theme={theme} />
<StatCell label="评论" value={stat.reply} theme={theme} />
<StatCell label="点赞" value={stat.like} theme={theme} />
<StatCell label="投币" value={stat.coin} theme={theme} />
<StatCell label="收藏" value={stat.favorite} theme={theme} />
</View>
)}
<View style={[styles.descDivider, { backgroundColor: theme.modalBorder }]} />
<Text style={[styles.descText, { color: theme.modalText }]}>
{video.desc?.trim() || "暂无简介"}
</Text>
</ScrollView>
</Animated.View>
</Modal>
);
}
function StatCell({
label,
value,
theme,
}: {
label: string;
value: number;
theme: ReturnType<typeof useTheme>;
}) {
return (
<View style={styles.statCell}>
<Text style={[styles.statValue, { color: theme.modalText }]}>
{formatCount(value ?? 0)}
</Text>
<Text style={[styles.statLabel, { color: theme.modalTextSub }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
sheet: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: "hidden",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingTop: 14,
paddingBottom: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
},
headerTitle: { fontSize: 16, fontWeight: "700" },
closeBtn: { padding: 4 },
body: { padding: 20, paddingBottom: 40 },
title: { fontSize: 17, fontWeight: "700", lineHeight: 24, marginBottom: 10 },
tnameBadge: {
alignSelf: "flex-start",
backgroundColor: "rgba(0,174,236,0.12)",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 3,
marginBottom: 10,
},
tnameText: { fontSize: 12, color: "#00AEEC" },
pubdate: { fontSize: 12, marginBottom: 14 },
statGrid: {
flexDirection: "row",
flexWrap: "wrap",
marginVertical: 4,
},
statCell: { width: "33.333%", alignItems: "center", paddingVertical: 8 },
statValue: { fontSize: 15, fontWeight: "700" },
statLabel: { fontSize: 11, marginTop: 2 },
descDivider: { height: StyleSheet.hairlineWidth, marginVertical: 16 },
descText: { fontSize: 14, lineHeight: 22 },
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react";
import React from "react";
import {
View,
Text,
@@ -6,14 +6,19 @@ import {
TouchableOpacity,
Animated,
StyleSheet,
ScrollView,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useDownload } from "../hooks/useDownload";
import { useTheme } from "../utils/theme";
import { useSheetTransition } from "../utils/useSheetTransition";
interface Props {
visible: boolean;
onClose: () => void;
/** Sheet 顶部对齐到屏幕的绝对 Y一般是播放器底部 */
topOffset: number;
bvid: string;
cid: number;
title: string;
@@ -24,6 +29,7 @@ interface Props {
export function DownloadSheet({
visible,
onClose,
topOffset,
bvid,
cid,
title,
@@ -32,46 +38,51 @@ export function DownloadSheet({
}: Props) {
const { tasks, startDownload, taskKey } = useDownload();
const theme = useTheme();
const slideAnim = useRef(new Animated.Value(300)).current;
const { height } = useWindowDimensions();
const sheetH = Math.max(120, height - topOffset);
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
useEffect(() => {
Animated.timing(slideAnim, {
toValue: visible ? 0 : 300,
duration: 260,
useNativeDriver: true,
}).start();
}, [visible]);
if (qualities.length === 0) return null;
if (!rendered || qualities.length === 0) return null;
return (
<Modal
visible={visible}
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
/>
<Animated.View
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
style={[
styles.sheet,
{
top: topOffset,
backgroundColor: theme.sheetBg,
transform: [{ translateY: slideAnim }],
},
]}
>
<View style={styles.header}>
<View style={[styles.header, { borderBottomColor: theme.modalBorder }]}>
<Text style={[styles.headerTitle, { color: theme.modalText }]}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Ionicons name="close" size={20} color={theme.modalTextSub} />
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<Ionicons name="close" size={22} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
<ScrollView
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
>
{qualities.map((q) => {
const key = taskKey(bvid, q.qn);
const task = tasks[key];
return (
<View key={q.qn} style={styles.row}>
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text>
<View
key={q.qn}
style={[styles.row, { borderBottomColor: theme.modalBorder }]}
>
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>
{q.desc}
</Text>
<View style={styles.right}>
{!task && (
<TouchableOpacity
@@ -95,18 +106,14 @@ export function DownloadSheet({
]}
/>
</View>
<Text style={styles.progressTxt}>
<Text style={[styles.progressTxt, { color: theme.modalTextSub }]}>
{Math.round(task.progress * 100)}%
</Text>
</View>
)}
{task?.status === "done" && (
<View style={styles.doneRow}>
<Ionicons
name="checkmark-circle"
size={16}
color="#00AEEC"
/>
<Ionicons name="checkmark-circle" size={16} color="#00AEEC" />
<Text style={styles.doneTxt}></Text>
</View>
)}
@@ -132,48 +139,42 @@ export function DownloadSheet({
</View>
);
})}
<View style={styles.footer} />
</ScrollView>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
},
sheet: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "#fff",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: 20,
paddingTop: 16,
bottom: 0,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: "hidden",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
paddingHorizontal: 20,
paddingTop: 14,
paddingBottom: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
},
headerTitle: { fontSize: 16, fontWeight: "700", color: "#212121" },
headerTitle: { fontSize: 16, fontWeight: "700" },
closeBtn: { padding: 4 },
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#eee",
marginBottom: 4,
},
body: { paddingHorizontal: 20, paddingBottom: 40 },
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
qualityLabel: { fontSize: 15, color: "#212121" },
qualityLabel: { fontSize: 15 },
right: { flexDirection: "row", alignItems: "center" },
downloadBtn: {
backgroundColor: "#00AEEC",
@@ -191,12 +192,11 @@ const styles = StyleSheet.create({
overflow: "hidden",
},
progressFill: { height: 4, backgroundColor: "#00AEEC", borderRadius: 2 },
progressTxt: { fontSize: 12, color: "#666", minWidth: 32 },
progressTxt: { fontSize: 12, minWidth: 32 },
doneRow: { flexDirection: "row", alignItems: "center", gap: 4 },
doneTxt: { fontSize: 13, color: "#00AEEC" },
errorWrap: { alignItems: "flex-end", gap: 2 },
errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" },
retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 },
retryTxt: { fontSize: 13, color: "#f44" },
footer: { height: 24 },
});

View File

@@ -0,0 +1,248 @@
import React, { useEffect, useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
Animated,
StyleSheet,
FlatList,
ActivityIndicator,
InteractionManager,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { CommentItem } from "./CommentItem";
import DanmakuList from "./DanmakuList";
import { useComments } from "../hooks/useComments";
import { useTheme } from "../utils/theme";
import { formatCount } from "../utils/format";
import { useSheetTransition } from "../utils/useSheetTransition";
import type { DanmakuItem } from "../services/types";
export type EngagementTab = "comments" | "danmaku";
interface Props {
visible: boolean;
onClose: () => void;
/** Sheet 顶部对齐到屏幕的绝对 Y一般是播放器底部 */
topOffset: number;
initialTab: EngagementTab;
aid: number;
replyCount?: number;
danmakus: DanmakuItem[];
currentTime: number;
}
export function EngagementSheet({
visible,
onClose,
topOffset,
initialTab,
aid,
replyCount,
danmakus,
currentTime,
}: Props) {
const theme = useTheme();
const { height } = useWindowDimensions();
const sheetH = Math.max(120, height - topOffset);
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
const [tab, setTab] = useState<EngagementTab>(initialTab);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const { comments, loading, hasMore, load } = useComments(aid, commentSort);
// 每次打开根据外部 initialTab 切到对应 Tab
useEffect(() => {
if (visible) setTab(initialTab);
}, [visible, initialTab]);
// 评论懒加载:仅当 Sheet 可见且选中评论 Tab 时拉
useEffect(() => {
if (!visible || !aid || tab !== "comments") return;
const handle = InteractionManager.runAfterInteractions(() => load());
return () => handle.cancel();
}, [visible, aid, commentSort, tab, load]);
if (!rendered) return null;
return (
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<Animated.View
style={[
styles.sheet,
{
top: topOffset,
backgroundColor: theme.sheetBg,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* Tab 栏 + 关闭按钮 */}
<View style={[styles.tabBar, { borderBottomColor: theme.modalBorder }]}>
{(["comments", "danmaku"] as EngagementTab[]).map((t) => {
const label =
t === "comments"
? `评论${replyCount ? ` ${formatCount(replyCount)}` : ""}`
: `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`;
const active = tab === t;
return (
<TouchableOpacity
key={t}
style={styles.tabItem}
onPress={() => setTab(t)}
activeOpacity={0.7}
>
<Text
style={[
styles.tabLabel,
{ color: theme.modalTextSub },
active && { color: theme.modalText, fontWeight: "700" },
]}
>
{label}
</Text>
{active && <View style={styles.tabUnderline} />}
</TouchableOpacity>
);
})}
<View style={{ flex: 1 }} />
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<Ionicons name="close" size={22} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
{/* 评论 Tab */}
{tab === "comments" && (
<>
<View style={[styles.sortRow, { borderBottomColor: theme.modalBorder }]}>
{([2, 0] as const).map((sort) => (
<TouchableOpacity
key={sort}
style={[
styles.sortBtn,
{ backgroundColor: theme.inputBg },
commentSort === sort && styles.sortBtnActive,
]}
onPress={() => setCommentSort(sort)}
>
<Text
style={[
styles.sortBtnTxt,
{ color: theme.modalTextSub },
commentSort === sort && styles.sortBtnTxtActive,
]}
>
{sort === 2 ? "热门" : "最新"}
</Text>
</TouchableOpacity>
))}
</View>
<FlatList
style={styles.list}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => {
if (hasMore && !loading) load();
}}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListFooterComponent={
loading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !hasMore && comments.length > 0 ? (
<Text style={[styles.emptyTxt, { color: theme.modalTextSub }]}>
</Text>
) : null
}
ListEmptyComponent={
!loading ? (
<Text style={[styles.emptyTxt, { color: theme.modalTextSub }]}>
</Text>
) : null
}
/>
</>
)}
{/* 弹幕 Tab —— hideHeader=true 让弹幕列表填满header 由本 Sheet 顶部 Tab 提供 */}
{tab === "danmaku" && (
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible
onToggle={() => {}}
hideHeader
style={styles.danmakuList}
/>
)}
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
sheet: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: "hidden",
},
tabBar: {
flexDirection: "row",
alignItems: "center",
borderBottomWidth: StyleSheet.hairlineWidth,
paddingHorizontal: 4,
},
tabItem: {
paddingVertical: 12,
paddingHorizontal: 14,
position: "relative",
alignItems: "center",
},
tabLabel: { fontSize: 14 },
tabUnderline: {
position: "absolute",
bottom: 0,
left: 14,
right: 14,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 1,
},
closeBtn: { padding: 10 },
sortRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 14,
paddingVertical: 8,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sortBtn: {
paddingHorizontal: 12,
paddingVertical: 3,
borderRadius: 16,
},
sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 13, fontWeight: "500" },
sortBtnTxtActive: { color: "#fff", fontWeight: "600" },
list: { flex: 1 },
loader: { marginVertical: 30 },
emptyTxt: { textAlign: "center", padding: 30, fontSize: 13 },
danmakuList: { flex: 1, borderTopWidth: 0 },
});

View File

@@ -1,12 +1,10 @@
import React, { useRef } from 'react';
import React, { useState, useEffect } from 'react';
import {
View,
Text,
Image,
StyleSheet,
Animated,
PanResponder,
Dimensions,
Platform,
} from 'react-native';
import { useRouter } from 'expo-router';
@@ -15,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useLiveStore } from '../store/liveStore';
import { useVideoStore } from '../store/videoStore';
import { proxyImageUrl } from '../utils/imageUrl';
import { useMiniDrag } from '../hooks/useMiniDrag';
const MINI_W = 160;
const MINI_H = 90;
@@ -25,70 +24,29 @@ const LIVE_HEADERS = {
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
};
function snapRelease(
pan: Animated.ValueXY,
curX: number,
curY: number,
sw: number,
sh: number,
) {
const snapRight = 0;
const snapLeft = -(sw - MINI_W - 24);
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
Animated.spring(pan, {
toValue: { x: snapX, y: clampedY },
useNativeDriver: false,
tension: 120,
friction: 10,
}).start();
}
export function LiveMiniPlayer() {
const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore();
const videoMiniActive = useVideoStore(s => s.isActive);
const router = useRouter();
const insets = useSafeAreaInsets();
const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false);
// 关闭时先把 Video 暂停一帧,让 native 释放连接,再 unmount
const [paused, setPaused] = useState(false);
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
const storeRef = useRef({ roomId, clearLive, router });
storeRef.current = { roomId, clearLive, router };
// 切换到不同直播间时重置 paused
useEffect(() => {
if (isActive) setPaused(false);
}, [hlsUrl, isActive]);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
isDragging.current = false;
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
pan.setValue({ x: 0, y: 0 });
const { pan, panHandlers } = useMiniDrag({
width: MINI_W,
height: MINI_H,
hitClose: (x, y) => x > MINI_W - 28 && y < 28,
onTap: () => router.push(`/live/${roomId}` as any),
onClose: () => {
setPaused(true);
requestAnimationFrame(() => clearLive());
},
onPanResponderMove: (_, gs) => {
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
isDragging.current = true;
}
pan.x.setValue(gs.dx);
pan.y.setValue(gs.dy);
},
onPanResponderRelease: (evt) => {
pan.flattenOffset();
if (!isDragging.current) {
const { locationX, locationY } = evt.nativeEvent;
const { roomId: rid, clearLive: clear, router: r } = storeRef.current;
if (locationX > MINI_W - 28 && locationY < 28) {
clear();
} else {
r.push(`/live/${rid}` as any);
}
return;
}
const { width: sw, height: sh } = Dimensions.get('window');
snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh);
},
onPanResponderTerminate: () => { pan.flattenOffset(); },
}),
).current;
});
if (!isActive) return null;
@@ -99,7 +57,7 @@ export function LiveMiniPlayer() {
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
{...panHandlers}
>
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.videoArea} />
<View style={styles.liveBadge} pointerEvents="none">
@@ -120,7 +78,7 @@ export function LiveMiniPlayer() {
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
{...panHandlers}
>
{/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */}
<View style={styles.videoArea} pointerEvents="none">
@@ -131,7 +89,7 @@ export function LiveMiniPlayer() {
resizeMode="cover"
controls={false}
muted={false}
paused={false}
paused={paused}
repeat={false}
onError={clearLive}
/>
@@ -141,7 +99,7 @@ export function LiveMiniPlayer() {
<Text style={styles.liveText}>LIVE</Text>
</View>
<Text style={styles.titleText} numberOfLines={1}>{title}</Text>
{/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */}
{/* 关闭按钮视觉层,点击逻辑由 hitClose 坐标判断 */}
<View style={styles.closeBtn}>
<Ionicons name="close" size={14} color="#fff" />
</View>

View File

@@ -87,7 +87,7 @@ export function LoginModal({ visible, onClose }: Props) {
if (result.code === 0 && result.cookie) {
clearInterval(pollRef.current!);
try {
await login(result.cookie, "", "");
await login(result.cookie, "", "", result.csrf);
} catch {
if (!cancelled) setStatus("error");
return;

View File

@@ -1,13 +1,11 @@
import React, { useRef } from 'react';
import {
View, Text, Image, StyleSheet,
Animated, PanResponder, Dimensions,
} from 'react-native';
import React from 'react';
import { View, Text, Image, StyleSheet, Animated } 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';
import { proxyImageUrl } from '../utils/imageUrl';
import { useMiniDrag } from '../hooks/useMiniDrag';
const MINI_W = 160;
const MINI_H = 90;
@@ -16,57 +14,14 @@ export function MiniPlayer() {
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
const router = useRouter();
const insets = useSafeAreaInsets();
const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false);
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
const storeRef = useRef({ bvid, clearVideo, router });
storeRef.current = { bvid, clearVideo, router };
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
isDragging.current = false;
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
pan.setValue({ x: 0, y: 0 });
},
onPanResponderMove: (_, gs) => {
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
isDragging.current = true;
}
pan.x.setValue(gs.dx);
pan.y.setValue(gs.dy);
},
onPanResponderRelease: (evt) => {
pan.flattenOffset();
if (!isDragging.current) {
const { locationX, locationY } = evt.nativeEvent;
const { bvid: vid, clearVideo: clear, router: r } = storeRef.current;
if (locationX > MINI_W - 28 && locationY < 28) {
clear();
} else {
r.push(`/video/${vid}` as any);
}
return;
}
const { width: sw, height: sh } = Dimensions.get('window');
const curX = (pan.x as any)._value;
const curY = (pan.y as any)._value;
const snapRight = 0;
const snapLeft = -(sw - MINI_W - 24);
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
Animated.spring(pan, {
toValue: { x: snapX, y: clampedY },
useNativeDriver: false,
tension: 120,
friction: 10,
}).start();
},
onPanResponderTerminate: () => { pan.flattenOffset(); },
})
).current;
const { pan, panHandlers } = useMiniDrag({
width: MINI_W,
height: MINI_H,
hitClose: (x, y) => x > MINI_W - 28 && y < 28,
onTap: () => router.push(`/video/${bvid}` as any),
onClose: clearVideo,
});
if (!isActive) return null;
@@ -75,11 +30,11 @@ export function MiniPlayer() {
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
{...panHandlers}
>
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.cover} />
<Text style={styles.title} numberOfLines={1}>{title}</Text>
{/* 关闭按钮仅作视觉展示,点击逻辑由 onPanResponderRelease 坐标判断处理 */}
{/* 关闭按钮仅作视觉展示,点击逻辑由 hitClose 坐标判断处理 */}
<View style={styles.closeBtn}>
<Ionicons name="close" size={14} color="#fff" />
</View>

View File

@@ -16,6 +16,8 @@ import {
Modal,
Image,
PanResponder,
ActivityIndicator,
Animated,
useWindowDimensions,
} from "react-native";
import Video, { VideoRef } from "react-native-video";
@@ -25,11 +27,13 @@ import type {
PlayUrlResponse,
VideoShotData,
DanmakuItem,
IVideoPlayer,
} from "../services/types";
import { buildDashMpdUri } from "../utils/dash";
import { getVideoShot } from "../services/bilibili";
import DanmakuOverlay from "./DanmakuOverlay";
import { useTheme } from "../utils/theme";
import { usePlayProgressStore } from "../store/playProgressStore";
const BAR_H = 3;
// 进度球尺寸
@@ -59,8 +63,8 @@ function findFrameByTime(index: number[], seekTime: number): number {
return lo;
}
export interface NativeVideoPlayerRef {
seek: (t: number) => void;
export interface NativeVideoPlayerRef extends IVideoPlayer {
/** @deprecated 用 pause()/resume() 代替 */
setPaused: (v: boolean) => void;
}
@@ -78,6 +82,12 @@ interface Props {
onTimeUpdate?: (t: number) => void;
initialTime?: number;
forcePaused?: boolean;
/** 点击播放器右上角"弹幕列表"图标,由外层打开 Sheet。仅在小窗口生效。 */
onDanmakuListPress?: () => void;
/** 点击播放器左上角返回箭头,跟随播放器控制栏一起显隐。仅在小窗口生效。 */
onBack?: () => void;
/** 视频封面 URL作为 poster 覆盖在 <Video> 上,首帧出来前用来盖住黑屏 */
coverUrl?: string;
}
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
@@ -96,6 +106,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
onTimeUpdate,
initialTime,
forcePaused,
onDanmakuListPress,
onBack,
coverUrl,
}: Props,
ref,
) {
@@ -105,11 +118,20 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
const isDash = !!playData?.dash;
// 封面挡片:用于盖住 <Video> 从挂载到 onLoad 出首帧之间的黑屏
// resolvedUrl 变化(新视频 / 换清晰度)时重新置 true等下一次 onLoad 再撤
const [coverVisible, setCoverVisible] = useState(true);
useEffect(() => {
setCoverVisible(true);
}, [resolvedUrl]);
const [showControls, setShowControls] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [paused, setPaused] = useState(false);
// seek 后强制触发 react-native-video 重新评估 paused prop 的 hack 用的瞬时叠加态
// 单独存储以避免污染 paused用于图标显示seek 完成的一瞬间不让"播放/暂停"图标闪
const [seekHackPaused, setSeekHackPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const currentTimeRef = useRef(0);
const [duration, setDuration] = useState(0);
@@ -118,15 +140,61 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
const [showQuality, setShowQuality] = useState(false);
// 倍速
const RATE_OPTIONS = [0.75, 1, 1.25, 1.5, 2];
const [rate, setRate] = useState(1);
const [showRate, setShowRate] = useState(false);
// 清晰度切换:保留进度 + loading 遮罩
const [switching, setSwitching] = useState(false);
const pendingSeekRef = useRef<number | null>(null);
const prevQnRef = useRef(currentQn);
const switchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 续播:每 5s 持久化一次
const lastSaveRef = useRef(0);
useEffect(() => {
// 排除初始挂载prevQn 或 currentQn === 0
if (
prevQnRef.current !== 0 &&
currentQn !== 0 &&
prevQnRef.current !== currentQn
) {
pendingSeekRef.current = currentTimeRef.current;
setSwitching(true);
// 兜底8s 内 onLoad 没触发就强制收起遮罩
if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current);
switchTimeoutRef.current = setTimeout(() => setSwitching(false), 8000);
}
prevQnRef.current = currentQn;
}, [currentQn]);
useEffect(() => {
return () => {
if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current);
};
}, []);
const [buffered, setBuffered] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
const isSeekingRef = useRef(false);
const [touchX, setTouchX] = useState<number | null>(null);
const touchXRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
// 拖动球位置用 Animated.Value 驱动setValue 不触发 React 重渲染,
// 原生层直接更新坐标,跟手 60fps。消除老方案 setState+60ms 节流导致的"段落感"。
const touchAnimX = useRef(new Animated.Value(0)).current;
// 缩略图换帧仍走 state精灵图位移涉及 RN 视图属性变化50ms 节流足够
const [thumbFrame, setThumbFrame] = useState<{
sheetIdx: number;
col: number;
row: number;
seekTime: number;
} | null>(null);
const thumbThrottleRef = useRef(0);
const barOffsetX = useRef(0);
const barWidthRef = useRef(300);
const trackRef = useRef<View>(null);
// 让稳定的 PanResponder 闭包能读到最新 shots
const shotsRef = useRef<VideoShotData | null>(null);
const [shots, setShots] = useState<VideoShotData | null>(null);
const [showDanmaku, setShowDanmaku] = useState(true);
@@ -137,6 +205,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
seek: (t: number) => {
videoRef.current?.seek(t);
},
pause: () => setPaused(true),
resume: () => setPaused(false),
getCurrentTime: () => currentTimeRef.current,
setPaused: (v: boolean) => {
setPaused(v);
},
@@ -153,7 +224,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
return;
}
if (isDash) {
buildDashMpdUri(playData, currentQn)
buildDashMpdUri(playData, currentQn, bvid)
.then(setResolvedUrl)
.catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl));
} else {
@@ -179,6 +250,25 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
durationRef.current = duration;
}, [duration]);
useEffect(() => {
shotsRef.current = shots;
}, [shots]);
// 非拖动时,球/进度填充随 currentTime 同步onProgress 驱动)
useEffect(() => {
if (isSeekingRef.current) return;
if (durationRef.current <= 0 || barWidthRef.current <= 0) {
touchAnimX.setValue(0);
return;
}
const x = clamp(
(currentTime / durationRef.current) * barWidthRef.current,
0,
barWidthRef.current,
);
touchAnimX.setValue(x);
}, [currentTime, duration]);
// 控制栏自动隐藏逻辑每次用户交互后重置计时器3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
@@ -223,7 +313,29 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
}
});
}, []);
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
// 拖动中按 50ms 节流计算精灵图帧索引;球位置不走这里,由 Animated.setValue 直接更新
const updateThumbFrame = useCallback((x: number) => {
const shotsData = shotsRef.current;
if (!shotsData || durationRef.current <= 0 || barWidthRef.current <= 0) return;
const ratio = clamp(x / barWidthRef.current, 0, 1);
const seekTime = ratio * durationRef.current;
const { img_x_len, img_y_len, image, index } = shotsData;
const framesPerSheet = img_x_len * img_y_len;
const totalFrames = framesPerSheet * image.length;
const frameIdx = index?.length
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
: clamp(Math.floor(ratio * (totalFrames - 1)), 0, totalFrames - 1);
const local = frameIdx % framesPerSheet;
setThumbFrame({
sheetIdx: Math.floor(frameIdx / framesPerSheet),
col: local % img_x_len,
row: Math.floor(local / img_x_len),
seekTime,
});
}, []);
// PanResponder 进度条拖动:球/进度填充走 Animated.setValue无 React 渲染),
// 缩略图换帧 50ms 节流;松手时 seek 到目标时间并恢复自动同步。
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
@@ -234,40 +346,39 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
setShowControls(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current);
touchXRef.current = x;
setTouchX(x);
touchAnimX.setValue(x);
thumbThrottleRef.current = 0;
updateThumbFrame(x);
},
onPanResponderMove: (_, gs) => {
touchXRef.current = clamp(
const x = clamp(
gs.moveX - barOffsetX.current,
0,
barWidthRef.current,
);
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setTouchX(touchXRef.current);
rafRef.current = null;
});
// 关键setValue 不触发 React 渲染,进度球/进度填充由原生层直接刷新
touchAnimX.setValue(x);
const now = Date.now();
if (now - thumbThrottleRef.current >= 50) {
thumbThrottleRef.current = now;
updateThumbFrame(x);
}
},
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动
onPanResponderRelease: (_, gs) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
const ratio = clamp(
(gs.moveX - barOffsetX.current) / barWidthRef.current,
const x = clamp(
gs.moveX - barOffsetX.current,
0,
1,
barWidthRef.current,
);
const ratio = barWidthRef.current > 0 ? x / barWidthRef.current : 0;
const t = ratio * durationRef.current;
touchAnimX.setValue(x);
videoRef.current?.seek(t);
setCurrentTime(t);
touchXRef.current = null;
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
setThumbFrame(null);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(
() => setShowControls(false),
@@ -275,71 +386,46 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
);
},
onPanResponderTerminate: () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
touchXRef.current = null;
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
setThumbFrame(null);
},
}),
).current;
// 进度条上触摸位置对应的时间点比例0-1。非拖动状态为 null
const touchRatio =
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const progressRatio =
duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
const THUMB_DISPLAY_W = 120; // scaled display width
// 缩略图尺寸:竖屏播放器较窄给 160全屏给 220 提升可读性
const THUMB_DISPLAY_W = isFullscreen ? 220 : 160;
// 进度球水平偏移active 态球更大translate 偏移需对齐圆心
const ballTranslate = React.useMemo(
() => Animated.subtract(touchAnimX, (isSeeking ? BALL_ACTIVE : BALL) / 2),
[isSeeking, touchAnimX],
);
const renderThumbnail = () => {
if (touchRatio === null || !shots || !isSeeking) return null;
if (!thumbFrame || !shots || !isSeeking) return null;
const {
img_x_size: TW,
img_y_size: TH,
img_x_len,
img_y_len,
image,
index,
} = shots;
const framesPerSheet = img_x_len * img_y_len;
const totalFrames = framesPerSheet * image.length;
const seekTime = touchRatio * duration;
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
const frameIdx =
index?.length && duration > 0
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
: clamp(
Math.floor(touchRatio * (totalFrames - 1)),
0,
totalFrames - 1,
);
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
const local = frameIdx % framesPerSheet;
const col = local % img_x_len;
const row = Math.floor(local / img_x_len);
const { sheetIdx, col, row, seekTime } = thumbFrame;
// 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比
const scale = THUMB_DISPLAY_W / TW;
const DW = THUMB_DISPLAY_W;
const DH = Math.round(TH * scale);
const trackLeft = barOffsetX.current;
const absLeft = clamp(
trackLeft + (touchX ?? 0) - DW / 2,
0,
SCREEN_W - DW,
);
// 缩略图固定到播放器水平中点,不跟随手指 / 进度球移动
const fixedLeft = Math.round(SCREEN_W / 2 - DW / 2);
const raw = image[sheetIdx];
if (!raw) return null;
// 兼容处理图床地址,确保以 http(s) 协议开头
const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}`
: image[sheetIdx];
const sheetUrl = raw.startsWith("//") ? `https:${raw}` : raw;
return (
<View
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
style={[styles.thumbPreview, { left: fixedLeft, width: DW }]}
pointerEvents="none"
>
<View
@@ -347,7 +433,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
width: DW,
height: DH,
overflow: "hidden",
borderRadius: 4,
borderRadius: 6,
}}
>
<Image
@@ -389,7 +475,8 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
style={StyleSheet.absoluteFill}
resizeMode="contain"
controls={false}
paused={!!(forcePaused || paused)}
paused={!!(forcePaused || paused || seekHackPaused)}
rate={rate}
progressUpdateInterval={500}
onProgress={({
currentTime: ct,
@@ -398,6 +485,14 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
}) => {
currentTimeRef.current = ct;
onTimeUpdate?.(ct);
// 续播持久化5s 节流)
if (bvid && dur > 0) {
const nowSave = Date.now();
if (nowSave - lastSaveRef.current > 5000) {
lastSaveRef.current = nowSave;
usePlayProgressStore.getState().save(bvid, ct, dur);
}
}
// 拖动进度条时跳过 UI 更新,避免与用户拖动冲突
if (isSeekingRef.current) return;
const now = Date.now();
@@ -408,26 +503,78 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
setBuffered(buf);
}}
onLoad={() => {
if (initialTime && initialTime > 0) {
// 首帧已就绪,撤掉 poster 挡片,让 <Video> 透出
setCoverVisible(false);
// 切清晰度后跳回原进度
const pending = pendingSeekRef.current;
let didSeek = false;
if (pending !== null && pending > 0) {
videoRef.current?.seek(pending);
pendingSeekRef.current = null;
didSeek = true;
} else if (initialTime && initialTime > 0) {
videoRef.current?.seek(initialTime);
didSeek = true;
}
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
if (!forcePaused) {
setPaused(true);
requestAnimationFrame(() => setPaused(false));
if (switching) {
setSwitching(false);
if (switchTimeoutRef.current) {
clearTimeout(switchTimeoutRef.current);
switchTimeoutRef.current = null;
}
}
// seek 后部分播放器不自动恢复播放,需短暂 paused→false 触发 prop 变化
// 仅在确实 seek 时执行;走 seekHackPaused不污染图标显示态避免播放/暂停图标闪烁
if (didSeek && !forcePaused && !paused) {
setSeekHackPaused(true);
requestAnimationFrame(() => setSeekHackPaused(false));
}
}}
onError={(e) => {
// 杜比视界播放失败时自动降级到 1080P
if (currentQn === 126) {
onQualityChange(80);
// 按降级链找下一档可用清晰度(从当前档位的下一档起,跳过不在 qualities 列表里的)
const FALLBACK_CHAIN = [126, 112, 80, 64, 32, 16];
const idx = FALLBACK_CHAIN.indexOf(currentQn);
if (idx >= 0) {
const acceptable = new Set(qualities.map(q => q.qn));
for (let i = idx + 1; i < FALLBACK_CHAIN.length; i++) {
const next = FALLBACK_CHAIN[i];
if (acceptable.has(next)) {
onQualityChange(next);
return;
}
}
}
console.warn("Video playback error:", e);
}}
/>
) : (
<View style={styles.placeholder} />
// 没拿到 resolvedUrl 之前用主题色 + 封面占位,避免页面背景 → 纯黑的撞色
<View style={[styles.placeholder, { backgroundColor: theme.card }]}>
{!!coverUrl && (
<Image
source={{ uri: coverUrl }}
style={StyleSheet.absoluteFill}
resizeMode="cover"
/>
)}
</View>
)}
{/* poster 挡片:<Video> 已挂载但首帧未到达时显示封面 */}
{!!resolvedUrl && coverVisible && !!coverUrl && (
<Image
source={{ uri: coverUrl }}
style={StyleSheet.absoluteFill}
resizeMode="cover"
pointerEvents="none"
/>
)}
{switching && (
<View style={styles.switchOverlay} pointerEvents="none">
<ActivityIndicator color="#fff" size="small" />
<Text style={styles.switchText}> {currentDesc}</Text>
</View>
)}
{isFullscreen && !!danmakus?.length && (
@@ -451,7 +598,32 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
colors={["rgba(0,0,0,0.55)", "transparent"]}
style={styles.topBar}
pointerEvents="box-none"
></LinearGradient>
>
{onBack && (
<TouchableOpacity
style={styles.topBtn}
onPress={() => {
onBack();
showAndReset();
}}
hitSlop={6}
>
<Ionicons name="chevron-back" size={22} color="#fff" />
</TouchableOpacity>
)}
<View style={{ flex: 1 }} />
{onDanmakuListPress && (
<TouchableOpacity
style={styles.topBtn}
onPress={() => {
onDanmakuListPress();
showAndReset();
}}
>
<Ionicons name="list-outline" size={20} color="#fff" />
</TouchableOpacity>
)}
</LinearGradient>
<TouchableOpacity
style={styles.centerBtn}
@@ -490,32 +662,26 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
},
]}
/>
<View
<Animated.View
style={[
styles.trackLayer,
{
width: `${progressRatio * 100}%` as any,
width: touchAnimX,
backgroundColor: "#00AEEC",
},
]}
/>
</View>
{isSeeking && touchX !== null ? (
<View
<Animated.View
style={[
styles.ball,
styles.ballActive,
{ left: touchX - BALL_ACTIVE / 2 },
isSeeking && styles.ballActive,
{
left: 0,
transform: [{ translateX: ballTranslate }],
},
]}
/>
) : (
<View
style={[
styles.ball,
{ left: progressRatio * barWidthRef.current - BALL / 2 },
]}
/>
)}
</View>
{/* Controls */}
@@ -538,6 +704,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
</Text>
<View style={{ flex: 1 }} />
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setShowRate(true)}
>
<Text style={styles.qualityText}>{rate === 1 ? "倍速" : `${rate}x`}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setShowQuality(true)}
@@ -601,6 +773,42 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
</View>
</TouchableOpacity>
</Modal>
{/* 选倍速 */}
<Modal visible={showRate} transparent animationType="fade">
<TouchableOpacity
style={styles.modalOverlay}
onPress={() => setShowRate(false)}
>
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityTitle, { color: theme.modalText }]}></Text>
{RATE_OPTIONS.map((r) => (
<TouchableOpacity
key={r}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
setRate(r);
setShowRate(false);
showAndReset();
}}
>
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
r === rate && styles.qualityItemActive,
]}
>
{r === 1 ? "正常" : `${r}x`}
</Text>
{r === rate && (
<Ionicons name="checkmark" size={16} color="#00AEEC" />
)}
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
</View>
);
},
@@ -610,6 +818,19 @@ const styles = StyleSheet.create({
container: { backgroundColor: "#000" },
fsContainer: { flex: 1, backgroundColor: "#000" },
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
switchOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.55)",
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 10,
},
switchText: {
color: "#fff",
fontSize: 13,
fontWeight: "500",
},
topBar: {
position: "absolute",
top: 0,

View File

@@ -0,0 +1,170 @@
import React from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
Share,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "../utils/theme";
import { formatCount } from "../utils/format";
import { toast } from "../utils/toast";
interface Props {
/** 视频统计:点赞/投币/收藏/评论数。读不到时按钮下方不显示数字。 */
stat?: {
like: number;
coin: number;
favorite: number;
reply?: number;
} | null;
/** 已加载的弹幕条数(拿不到时按钮显示纯文字"弹幕")。 */
danmakuCount?: number;
/** 分享时显示的视频标题。 */
title?: string;
/** 分享时附带的链接(一般是 https://www.bilibili.com/video/BVxxx。 */
shareUrl?: string;
onDownload: () => void;
onComments: () => void;
onDanmaku: () => void;
}
export const VideoActionRow = React.memo(function VideoActionRow({
stat,
danmakuCount,
title,
shareUrl,
onDownload,
onComments,
onDanmaku,
}: Props) {
const theme = useTheme();
const handleShare = async () => {
if (!shareUrl) {
toast("暂无分享链接");
return;
}
try {
await Share.share({
message: title ? `${title}\n${shareUrl}` : shareUrl,
url: shareUrl,
});
} catch {
// 用户取消或系统拒绝,无需处理
}
};
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{/* <Btn
icon="thumbs-up-outline"
label={stat?.like ? formatCount(stat.like) : "点赞"}
onPress={() => toast("暂未实现")}
color={theme.text}
bg={theme.inputBg}
/> */}
{/* <Btn
icon="thumbs-down-outline"
label="不喜欢"
onPress={() => toast("暂未实现")}
color={theme.text}
bg={theme.inputBg}
/> */}
<Btn
icon="chatbubble-outline"
label={stat?.reply ? formatCount(stat.reply) : "评论"}
onPress={onComments}
color={theme.text}
bg={theme.inputBg}
/>
<Btn
icon="chatbox-ellipses-outline"
label={danmakuCount ? formatCount(danmakuCount) : "弹幕"}
onPress={onDanmaku}
color={theme.text}
bg={theme.inputBg}
/>
{/* <Btn
icon="logo-bitcoin"
label={stat?.coin ? formatCount(stat.coin) : "投币"}
onPress={() => toast("暂未实现")}
color={theme.text}
bg={theme.inputBg}
/> */}
<Btn
icon="cloud-download-outline"
label="下载"
onPress={onDownload}
color={theme.text}
bg={theme.inputBg}
/>
<Btn
icon="arrow-redo-outline"
label="分享"
onPress={handleShare}
color={theme.text}
bg={theme.inputBg}
/>
<Btn
icon="star-outline"
label={stat?.favorite ? formatCount(stat.favorite) : "收藏"}
onPress={() => toast("暂未实现")}
color={theme.text}
bg={theme.inputBg}
/>
</ScrollView>
);
});
function Btn({
icon,
label,
onPress,
color,
bg,
}: {
icon: React.ComponentProps<typeof Ionicons>["name"];
label: string;
onPress: () => void;
color: string;
bg: string;
}) {
return (
<TouchableOpacity
style={[styles.btn, { backgroundColor: bg }]}
onPress={onPress}
activeOpacity={0.85}
>
<Ionicons name={icon} size={16} color={color} />
<Text style={[styles.btnLabel, { color }]} numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
gap: 8,
},
btn: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 18,
},
btnLabel: { fontSize: 13, fontWeight: "600" },
});

View File

@@ -0,0 +1,146 @@
import React, { useEffect, useRef, memo } from "react";
import { View, StyleSheet, Animated } from "react-native";
import { useTheme } from "../utils/theme";
// 性能优化:颜色由顶层 useTheme 一次拿到,所有 block 走 inline style避免 40+ store 订阅。
// 布局:与详情页 ListHeaderComponent 结构同步 ——
// 标题块 → Meta 行 → 动作按钮行 → 创作者行 → 推荐视频标题 → 推荐卡片
interface BlockProps {
width: number | `${number}%`;
height: number;
radius?: number;
bg: string;
style?: any;
}
const SkeletonBlock = memo(function SkeletonBlock({ width, height, radius = 4, bg, style }: BlockProps) {
return (
<View
style={[
{ width, height, borderRadius: radius, backgroundColor: bg },
style,
]}
/>
);
});
const RelatedCardSkeleton = memo(function RelatedCardSkeleton({ bg, border }: { bg: string; border: string }) {
return (
<View style={[styles.card, { borderBottomColor: border }]}>
<SkeletonBlock width={120} height={68} radius={4} bg={bg} />
<View style={styles.cardInfo}>
<View>
<SkeletonBlock width={"100%"} height={12} bg={bg} />
<SkeletonBlock width={"70%"} height={12} bg={bg} style={{ marginTop: 6 }} />
</View>
<View style={styles.cardMeta}>
<SkeletonBlock width={70} height={10} bg={bg} />
<SkeletonBlock width={50} height={10} bg={bg} />
</View>
</View>
</View>
);
});
// 一个动作按钮占位pill 形状,匹配 VideoActionRow 实际按钮形态)
const ActionPillSkeleton = memo(function ActionPillSkeleton({ bg, width }: { bg: string; width: number }) {
return <SkeletonBlock width={width} height={28} radius={14} bg={bg} />;
});
const CARD_COUNT = 4;
export function VideoDetailSkeleton() {
const theme = useTheme();
const opacity = useRef(new Animated.Value(0.6)).current;
useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(opacity, { toValue: 1, duration: 800, useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0.6, duration: 800, useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [opacity]);
const bg = theme.placeholder;
const border = theme.border;
return (
<Animated.View style={[styles.container, { backgroundColor: theme.card, opacity }]}>
{/* 标题块(两行) + Meta 行 */}
<View style={styles.titleBlock}>
<SkeletonBlock width={"92%"} height={16} bg={bg} />
<SkeletonBlock width={"60%"} height={16} bg={bg} style={{ marginTop: 8 }} />
<SkeletonBlock width={"75%"} height={12} bg={bg} style={{ marginTop: 10 }} />
</View>
{/* 动作按钮行 */}
<View style={[styles.actionRow, { borderTopColor: border }]}>
<ActionPillSkeleton bg={bg} width={72} />
<ActionPillSkeleton bg={bg} width={72} />
<ActionPillSkeleton bg={bg} width={72} />
<ActionPillSkeleton bg={bg} width={72} />
<ActionPillSkeleton bg={bg} width={72} />
</View>
{/* 创作者行 + 关注按钮 */}
<View style={[styles.creatorRow, { borderTopColor: border, borderBottomColor: border }]}>
<SkeletonBlock width={38} height={38} radius={19} bg={bg} />
<View style={styles.creatorInfo}>
<SkeletonBlock width={120} height={14} bg={bg} />
<SkeletonBlock width={160} height={11} bg={bg} style={{ marginTop: 6 }} />
</View>
<SkeletonBlock width={56} height={28} radius={14} bg={bg} />
</View>
{/* 推荐视频标题 */}
<View style={styles.relatedHeader}>
<SkeletonBlock width={64} height={13} bg={bg} />
</View>
{/* 推荐卡片占位4 张足够撑满首屏,少绘制开销) */}
{Array.from({ length: CARD_COUNT }).map((_, i) => (
<RelatedCardSkeleton key={i} bg={bg} border={border} />
))}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12 },
actionRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
gap: 8,
borderTopWidth: StyleSheet.hairlineWidth,
},
creatorRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
creatorInfo: { flex: 1, marginLeft: 10 },
relatedHeader: {
paddingLeft: 13,
paddingTop: 12,
paddingBottom: 8,
},
card: {
flexDirection: "row",
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
},
cardInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
cardMeta: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
});

View File

@@ -1,10 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native';
import { Image } from 'expo-image';
// expo-screen-orientation requires a dev build; gracefully degrade in Expo Go
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer';
import type { PlayUrlResponse, DanmakuItem } from '../services/types';
import { useTheme } from '../utils/theme';
interface Props {
playData: PlayUrlResponse | null;
@@ -15,14 +17,28 @@ interface Props {
cid?: number;
danmakus?: DanmakuItem[];
onTimeUpdate?: (t: number) => void;
initialTime?: number;
/** 详情页将其挂到弹幕 Sheet 的打开动作。仅小窗口生效,全屏暂不显示。 */
onDanmakuListPress?: () => void;
/** 播放器左上角返回按钮回调,跟随小窗口控制栏一起显隐。 */
onBack?: () => void;
/** 视频封面 URL。playData 未到达 / <Video> 首帧未到达时用作 poster避免黑闪 */
coverUrl?: string;
}
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate }: Props) {
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate, initialTime, onDanmakuListPress, onBack, coverUrl }: Props) {
const [fullscreen, setFullscreen] = useState(false);
const { width, height } = useWindowDimensions();
const VIDEO_HEIGHT = width * 0.5625;
const needsRotation = !ScreenOrientation && fullscreen;
const lastTimeRef = useRef(0);
const seededRef = useRef(false);
const theme = useTheme();
// 续播:第一次拿到 initialTime 时塞进 lastTimeRef
if (!seededRef.current && typeof initialTime === 'number' && initialTime > 0) {
lastTimeRef.current = initialTime;
seededRef.current = true;
}
const portraitRef = useRef<NativeVideoPlayerRef>(null);
const handleEnterFullscreen = async () => {
@@ -45,9 +61,13 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
}, []);
if (!playData) {
// 没拿到 playData 时背景用主题色(与页面同色,消除"页面→纯黑"撞色),有封面则铺封面
return (
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}>
<Text style={styles.placeholderText}>...</Text>
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: theme.card }, styles.placeholder]}>
{!!coverUrl && (
<Image source={{ uri: coverUrl }} style={StyleSheet.absoluteFill} contentFit="cover" />
)}
<Text style={[styles.placeholderText, { color: coverUrl ? '#fff' : theme.textSub }]}>...</Text>
</View>
);
}
@@ -58,6 +78,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
<View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
<video
src={url}
poster={coverUrl}
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
controls
playsInline
@@ -82,6 +103,9 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
isFullscreen={false}
initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
onDanmakuListPress={onDanmakuListPress}
onBack={onBack}
coverUrl={coverUrl}
/>
)}
@@ -105,6 +129,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
isFullscreen={true}
initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
coverUrl={coverUrl}
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
/>
</View>
@@ -117,5 +142,5 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
const styles = StyleSheet.create({
placeholder: { justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#fff', fontSize: 14 },
placeholderText: { fontSize: 14 },
});

46
hooks/useAutoHideTimer.ts Normal file
View File

@@ -0,0 +1,46 @@
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* 控制栏自动隐藏 hookshow() 后 delayMs 内无交互即隐藏。
* keep() 用于"按住进度条不让消失"等场景:返回 true 时计时器会被推迟。
*/
export function useAutoHideTimer(delayMs = 3000, keep?: () => boolean) {
const [visible, setVisible] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reset = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (keep?.()) return;
timerRef.current = setTimeout(() => setVisible(false), delayMs);
}, [delayMs, keep]);
const show = useCallback(() => {
setVisible(true);
reset();
}, [reset]);
const hide = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setVisible(false);
}, []);
const toggle = useCallback(() => {
setVisible(prev => {
if (!prev) {
reset();
return true;
}
if (timerRef.current) clearTimeout(timerRef.current);
return false;
});
}, [reset]);
useEffect(() => {
reset();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { visible, show, hide, toggle, reset };
}

68
hooks/useFollow.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getRelation, modifyRelation } from "../services/bilibili";
import { useAuthStore } from "../store/authStore";
import { getSecure } from "../utils/secureStorage";
import { toast } from "../utils/toast";
/**
* 关注 / 取消关注 UP 主。
* - 未登录toggle 时 toast 提示,按钮 disabled=false 仍允许点击
* - 已登录但无 bili_jcttoggle 时 toast 提示让用户重登
* - 正常路径:乐观更新 UI请求失败回滚
*/
export function useFollow(mid: number | undefined) {
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const [following, setFollowing] = useState(false);
const [loading, setLoading] = useState(false);
const inflightRef = useRef(false);
// 首次拉取 / mid 切换时刷新关注状态
useEffect(() => {
if (!mid || !isLoggedIn) {
setFollowing(false);
return;
}
let cancelled = false;
getRelation(mid)
.then((r) => {
if (!cancelled) setFollowing(r.following);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [mid, isLoggedIn]);
const toggle = useCallback(async () => {
if (!mid) return;
if (!isLoggedIn) {
toast("请先登录后再关注");
return;
}
const biliJct = await getSecure("bili_jct");
if (!biliJct) {
toast("请重新登录后再使用关注功能");
return;
}
if (inflightRef.current) return;
inflightRef.current = true;
setLoading(true);
const next = !following;
setFollowing(next); // 乐观更新
try {
await modifyRelation(mid, next ? 1 : 2);
} catch (e: any) {
setFollowing(!next); // 失败回滚
if (e?.message === "NO_CSRF") {
toast("请重新登录后再使用关注功能");
} else {
toast(`操作失败:${e?.message || "未知错误"}`);
}
} finally {
inflightRef.current = false;
setLoading(false);
}
}, [mid, isLoggedIn, following]);
return { following, loading, toggle };
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { getLiveDanmakuHistory } from '../services/bilibili';
import type { DanmakuItem } from '../services/types';
const POLL_INTERVAL = 3000;
const POLL_INTERVAL = 1500;
// 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1"
const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/;
@@ -36,9 +36,10 @@ export function useLiveDanmaku(roomId: number): {
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
if (cancelled) return;
// 去重弹幕
// 去重弹幕:以 uname + text + timeline 为 key
// 用 timeline 区分"同一人重复发同句"的合法重复gethistory 多次轮询会返回相同尾部数据,靠 timeline 过滤掉)
const newItems = items.filter(item => {
const key = `${item.uname ?? ''}:${item.text}`;
const key = `${item.uname ?? ''}:${item.text}:${item.timeline ?? ''}`;
if (seenTextsRef.current.has(key)) return false;
seenTextsRef.current.add(key);
return true;
@@ -50,7 +51,6 @@ export function useLiveDanmaku(roomId: number): {
// 解析 admin 消息中的礼物
const newGifts: Record<string, number> = {};
for (const msg of adminMsgs) {
console.log(msg,123)
// 去重 admin 消息
if (seenAdminRef.current.has(msg)) continue;
seenAdminRef.current.add(msg);

70
hooks/useMiniDrag.ts Normal file
View File

@@ -0,0 +1,70 @@
import { useRef } from 'react';
import { Animated, Dimensions, PanResponder } from 'react-native';
interface Options {
width: number;
height: number;
/** 命中"关闭按钮"区域的判定locationX, locationY→ true 时调 onClose否则调 onTap */
hitClose?: (locationX: number, locationY: number) => boolean;
onTap: () => void;
onClose?: () => void;
}
/**
* 全局浮动小窗的拖拽 + 吸边 + 点击/关闭分发。
* 返回 panHandlers 直接展开到容器transform 应用 pan.getTranslateTransform()。
*/
export function useMiniDrag({ width, height, hitClose, onTap, onClose }: Options) {
const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false);
// 用 ref 保持最新回调,避免 PanResponder 闭包过期
const cbRef = useRef({ onTap, onClose, hitClose });
cbRef.current = { onTap, onClose, hitClose };
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
isDragging.current = false;
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
pan.setValue({ x: 0, y: 0 });
},
onPanResponderMove: (_, gs) => {
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
isDragging.current = true;
}
pan.x.setValue(gs.dx);
pan.y.setValue(gs.dy);
},
onPanResponderRelease: (evt) => {
pan.flattenOffset();
if (!isDragging.current) {
const { locationX, locationY } = evt.nativeEvent;
const cb = cbRef.current;
if (cb.hitClose?.(locationX, locationY) && cb.onClose) {
cb.onClose();
} else {
cb.onTap();
}
return;
}
const { width: sw, height: sh } = Dimensions.get('window');
const curX = (pan.x as any)._value;
const curY = (pan.y as any)._value;
const snapRight = 0;
const snapLeft = -(sw - width - 24);
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
const clampedY = Math.max(-sh + height + 60, Math.min(60, curY));
Animated.spring(pan, {
toValue: { x: snapX, y: clampedY },
useNativeDriver: false,
tension: 120,
friction: 10,
}).start();
},
onPanResponderTerminate: () => { pan.flattenOffset(); },
}),
).current;
return { pan, panHandlers: panResponder.panHandlers };
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { getVideoRelated } from '../services/bilibili';
import type { VideoItem } from '../services/types';
@@ -7,6 +7,11 @@ export function useRelatedVideos(bvid: string) {
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 切到不同 bvid 时立刻清空,避免新页面短暂显示上一支视频的推荐流
useEffect(() => {
setVideos([]);
}, [bvid]);
const load = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { getVideoDetail, getPlayUrl } from '../services/bilibili';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import { usePlayProgressStore } from '../store/playProgressStore';
import type { VideoItem, PlayUrlResponse } from '../services/types';
export function useVideoDetail(bvid: string) {
@@ -11,6 +12,7 @@ export function useVideoDetail(bvid: string) {
const [currentQn, setCurrentQn] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [initialTime, setInitialTime] = useState(0);
const cidRef = useRef<number>(0);
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const trafficSaving = useSettingsStore(s => s.trafficSaving);
@@ -35,9 +37,17 @@ export function useVideoDetail(bvid: string) {
}
useEffect(() => {
// bvid 切换时立刻清空旧数据,防止上一支视频的播放器/简介/清晰度短暂"残影"造成抖动
setVideo(null);
setPlayData(null);
setQualities([]);
setCurrentQn(0);
cidRef.current = 0;
async function fetchData() {
try {
setLoading(true);
// 读取续播位置
setInitialTime(usePlayProgressStore.getState().get(bvid));
const detail = await getVideoDetail(bvid);
setVideo(detail);
const cid = detail.pages?.[0]?.cid ?? detail.cid as number;
@@ -53,13 +63,32 @@ export function useVideoDetail(bvid: string) {
}, [bvid]);
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
// cancelled flag 防止旧响应(切换登录态后)覆盖新响应
useEffect(() => {
if (cidRef.current) {
fetchPlayData(cidRef.current, defaultQn, true).catch((e) => {
console.warn('Failed to refresh quality list after login change:', e);
});
if (!cidRef.current) return;
let cancelled = false;
(async () => {
try {
const data = await getPlayUrl(bvid, cidRef.current, defaultQn);
if (cancelled) return;
setPlayData(data);
setCurrentQn(data.quality);
if (data.accept_quality?.length) {
setQualities(
data.accept_quality.map((q, i) => ({
qn: q,
desc: data.accept_description?.[i] ?? String(q),
})),
);
}
} catch (e) {
if (!cancelled) console.warn('Failed to refresh quality list after login change:', e);
}
})();
return () => {
cancelled = true;
};
}, [isLoggedIn]);
return { video, playData, loading, error, qualities, currentQn, changeQuality };
return { video, playData, loading, error, qualities, currentQn, changeQuality, initialTime };
}

View File

@@ -1,14 +1,12 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import { getRecommendFeed, getLiveList } from '../services/bilibili';
import type { VideoItem, LiveRoom } from '../services/types';
import { getRecommendFeed } from '../services/bilibili';
import type { VideoItem } from '../services/types';
export function useVideoList() {
const [pages, setPages] = useState<VideoItem[][]>([]);
const [liveRooms, setLiveRooms] = useState<LiveRoom[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Use refs to avoid stale closures — load() has stable identity
const loadingRef = useRef(false);
const freshIdxRef = useRef(0);
@@ -21,19 +19,8 @@ export function useVideoList() {
const idx = freshIdxRef.current;
setLoading(true);
try {
const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [
getRecommendFeed(idx),
(reset || idx === 0)
? getLiveList(1, 0).catch(() => [] as LiveRoom[])
: Promise.resolve([] as LiveRoom[]),
];
const [data, live] = await Promise.all(promises);
const data = await getRecommendFeed(idx);
setPages(prev => reset ? [data] : [...prev, data]);
if (reset || idx === 0) {
// Take top 2 by online count
const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 10);
setLiveRooms(sorted);
}
freshIdxRef.current = idx + 1;
} catch (e) {
console.error('Failed to load videos', e);
@@ -42,7 +29,7 @@ export function useVideoList() {
setLoading(false);
setRefreshing(false);
}
}, []); // stable — no stale closure risk
}, []);
const refresh = useCallback(() => {
console.log('Refreshing video list');
@@ -51,5 +38,5 @@ export function useVideoList() {
}, [load]);
const videos = useMemo(() => pages.flat(), [pages]);
return { videos, pages, liveRooms, loading, refreshing, load, refresh };
return { videos, pages, loading, refreshing, load, refresh };
}

View File

@@ -1,6 +1,6 @@
{
"name": "jkvideo",
"version": "1.0.18",
"version": "1.0.19",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",

View File

@@ -5,7 +5,7 @@ import pako from 'pako';
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types';
import { signWbi } from '../utils/wbi';
import { parseDanmakuXml } from '../utils/danmaku';
import { getSecure } from '../utils/secureStorage';
import { getSecure, setSecure } from '../utils/secureStorage';
const isWeb = Platform.OS === 'web';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
@@ -46,22 +46,41 @@ const api = axios.create({
});
api.interceptors.request.use(async (config) => {
const [sessdata, buvid3] = await Promise.all([
const [sessdata, biliJct, buvid3] = await Promise.all([
getSecure('SESSDATA'),
getSecure('bili_jct'),
getBuvid3(),
]);
if (isWeb) {
// Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy
if (buvid3) config.headers['X-Buvid3'] = buvid3;
if (sessdata) config.headers['X-Sessdata'] = sessdata;
if (biliJct) config.headers['X-Bili-Jct'] = biliJct;
} else {
const cookies: string[] = [`buvid3=${buvid3}`];
if (sessdata) cookies.push(`SESSDATA=${sessdata}`);
if (biliJct) cookies.push(`bili_jct=${biliJct}`);
config.headers['Cookie'] = cookies.join('; ');
}
return config;
});
// 被动累积:任何响应 Set-Cookie 里出现 bili_jct 都顺手存下来。覆盖老用户没有 CSRF 的情况。
api.interceptors.response.use(async (response) => {
if (isWeb) return response;
const setCookie = response.headers?.['set-cookie'];
if (Array.isArray(setCookie)) {
for (const c of setCookie) {
const part = c.split(';').find((p: string) => p.trim().startsWith('bili_jct='));
if (part) {
const value = part.trim().replace('bili_jct=', '');
if (value) await setSecure('bili_jct', value);
}
}
}
return response;
});
// ─── Request deduplication ──────────────────────────────────────────────────
// Prevents identical concurrent requests (same URL + params) from hitting the network twice.
const inflightRequests = new Map<string, Promise<any>>();
@@ -79,6 +98,32 @@ function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
return promise;
}
/**
* 指数退避重试包装。仅对网络层面错误(无 response、5xx、超时重试
* 业务错误(如 401/403/-352直接抛出不重试。
*/
async function withRetry<T>(
fn: () => Promise<T>,
retries = 2,
backoff = [500, 1500],
): Promise<T> {
let lastErr: any;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (e: any) {
lastErr = e;
const status = e?.response?.status;
const isNetwork = !e?.response || e?.code === 'ECONNABORTED';
const is5xx = typeof status === 'number' && status >= 500;
if (!isNetwork && !is5xx) throw e;
if (attempt === retries) throw e;
await new Promise((r) => setTimeout(r, backoff[attempt] ?? backoff[backoff.length - 1]));
}
}
throw lastErr;
}
// WBI key cache (rotates ~daily)
let wbiKeys: { imgKey: string; subKey: string } | null = null;
let wbiKeysTimestamp = 0;
@@ -128,10 +173,12 @@ export async function getPopularVideos(pn = 1): Promise<VideoItem[]> {
}
export function getVideoDetail(bvid: string): Promise<VideoItem> {
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => {
return dedupe(dedupeKey('/x/web-interface/view', { bvid }), () =>
withRetry(async () => {
const res = await api.get('/x/web-interface/view', { params: { bvid } });
return res.data.data as VideoItem;
});
}),
);
}
export async function getVideoRelated(bvid: string): Promise<VideoItem[]> {
@@ -147,10 +194,12 @@ export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlR
const params = isAndroid
? { bvid, cid, qn, fnval: FNVAL_ANDROID, fourk: 1 }
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
return dedupe(dedupeKey('/x/player/playurl', params), async () => {
return dedupe(dedupeKey('/x/player/playurl', params), () =>
withRetry(async () => {
const res = await api.get('/x/player/playurl', { params });
return res.data.data as PlayUrlResponse;
});
}),
);
}
export async function getPlayUrlForDownload(
@@ -266,7 +315,7 @@ export async function generateQRCode(): Promise<QRCodeInfo> {
return res.data.data as QRCodeInfo;
}
export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string }> {
export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string; csrf?: string }> {
const headers = isWeb
? {}
: { 'Referer': 'https://www.bilibili.com' };
@@ -276,22 +325,58 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
});
const { code } = res.data.data;
let cookie: string | undefined;
let csrf: string | undefined;
if (code === 0) {
if (isWeb) {
// Proxy relays SESSDATA via custom response header
cookie = res.headers['x-sessdata'] as string | undefined;
csrf = res.headers['x-bili-jct'] as string | undefined;
} else {
const setCookie = res.headers['set-cookie'];
const match = setCookie?.find((c: string) => c.includes('SESSDATA='));
if (match) {
const sessPart = match.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
if (sessPart) {
cookie = sessPart.trim().replace('SESSDATA=', '');
if (Array.isArray(setCookie)) {
for (const c of setCookie) {
const sessPart = c.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
if (sessPart) cookie = sessPart.trim().replace('SESSDATA=', '');
const jctPart = c.split(';').find((p: string) => p.trim().startsWith('bili_jct='));
if (jctPart) csrf = jctPart.trim().replace('bili_jct=', '');
}
}
}
}
return { code, cookie };
return { code, cookie, csrf };
}
// 查关注状态attribute: 0=未关注, 2=关注, 6=互相关注)
export async function getRelation(mid: number): Promise<{ following: boolean; mutual: boolean }> {
try {
const res = await api.get('/x/relation', { params: { fid: mid } });
const attribute = res.data?.data?.attribute ?? 0;
return {
following: attribute === 2 || attribute === 6,
mutual: attribute === 6,
};
} catch {
return { following: false, mutual: false };
}
}
/** 修改与 UP 主的关系act=1 关注act=2 取消关注。失败抛错(消息文本来自 B 站 code/message */
export async function modifyRelation(mid: number, act: 1 | 2): Promise<void> {
const biliJct = await getSecure('bili_jct');
if (!biliJct) throw new Error('NO_CSRF');
// application/x-www-form-urlencoded body
const body =
`fid=${encodeURIComponent(String(mid))}` +
`&act=${act}` +
`&re_src=11` +
`&csrf=${encodeURIComponent(biliJct)}`;
const res = await api.post('/x/relation/modify', body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const code = res.data?.code;
if (code !== 0) {
throw new Error(res.data?.message || `code=${code}`);
}
}
@@ -443,8 +528,9 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
danmakus: DanmakuItem[];
adminMsgs: string[];
}> {
// gethistory 默认返回 ~10 条;尝试带 count 参数拉更多B 站不支持时会自动忽略
const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/dM/gethistory`, {
params: { roomid: roomId },
params: { roomid: roomId, room_id: roomId, count: 30 },
});
const room: any[] = res.data?.data?.room ?? [];
@@ -467,7 +553,7 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{
}
export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
try {
return withRetry(async () => {
if (isWeb) {
// web 走代理,代理已解压,直接拿文本
const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, {
@@ -501,10 +587,10 @@ export async function getDanmaku(cid: number): Promise<DanmakuItem[]> {
}
return parseDanmakuXml(xmlText);
} catch (e) {
}).catch((e) => {
console.warn('getDanmaku failed:', e);
return [];
}
return [] as DanmakuItem[];
});
}
export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {

View File

@@ -1,3 +1,14 @@
/**
* 三种播放器(视频详情、首页大卡、直播)暴露的统一 ref 接口。
* 不一定每个实现都全部支持,未实现的方法可空操作。
*/
export interface IVideoPlayer {
seek: (timeSec: number) => void;
pause: () => void;
resume: () => void;
getCurrentTime: () => number;
}
export interface VideoItem {
bvid: string;
aid: number;

View File

@@ -5,11 +5,13 @@ import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage';
interface AuthState {
sessdata: string | null;
/** B 站 CSRF tokenbili_jct cookie 值)。写操作(关注/点赞/投币 etc必填 */
biliJct: string | null;
uid: string | null;
username: string | null;
face: string | null;
isLoggedIn: boolean;
login: (sessdata: string, uid: string, username?: string) => Promise<void>;
login: (sessdata: string, uid: string, username?: string, biliJct?: string) => Promise<void>;
logout: () => Promise<void>;
restore: () => Promise<void>;
setProfile: (face: string, username: string, uid: string) => void;
@@ -17,16 +19,24 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set) => ({
sessdata: null,
biliJct: null,
uid: null,
username: null,
face: null,
isLoggedIn: false,
login: async (sessdata, uid, username) => {
login: async (sessdata, uid, username, biliJct) => {
await setSecure('SESSDATA', sessdata);
if (biliJct) await setSecure('bili_jct', biliJct);
// Migrate: remove SESSDATA from AsyncStorage if it was there before
await AsyncStorage.removeItem('SESSDATA').catch(() => { });
set({ sessdata, uid: uid || null, username: username || null, isLoggedIn: true });
set({
sessdata,
biliJct: biliJct ?? null,
uid: uid || null,
username: username || null,
isLoggedIn: true,
});
getUserInfo().then(async (info) => {
await AsyncStorage.multiSet([
['UID', String(info.mid)],
@@ -39,8 +49,9 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: async () => {
await deleteSecure('SESSDATA');
await deleteSecure('bili_jct');
await AsyncStorage.multiRemove(['UID', 'USERNAME', 'FACE']);
set({ sessdata: null, uid: null, username: null, face: null, isLoggedIn: false });
set({ sessdata: null, biliJct: null, uid: null, username: null, face: null, isLoggedIn: false });
},
restore: async () => {
@@ -54,8 +65,9 @@ export const useAuthStore = create<AuthState>((set) => ({
await AsyncStorage.removeItem('SESSDATA');
}
}
const biliJct = await getSecure('bili_jct');
if (sessdata) {
set({ sessdata, isLoggedIn: true });
set({ sessdata, biliJct, isLoggedIn: true });
try {
const info = await getUserInfo();
await AsyncStorage.setItem('FACE', info.face);

30
store/miniExclusion.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* MiniPlayer ↔ LiveMiniPlayer 互斥协调。
* 通过 zustand subscribe 在外部串联两个 store避免它们直接互相 import
* 造成循环依赖(曾导致 Metro / TS 编译时内存暴涨)。
*
* 仅需在 app 启动时 import 一次_layout.tsx即可生效。
*/
import { useVideoStore } from './videoStore';
import { useLiveStore } from './liveStore';
let initialized = false;
export function initMiniExclusion() {
if (initialized) return;
initialized = true;
// 视频 mini 激活 → 清掉直播 mini
useVideoStore.subscribe((state, prev) => {
if (state.isActive && !prev.isActive && useLiveStore.getState().isActive) {
useLiveStore.getState().clearLive();
}
});
// 直播 mini 激活 → 清掉视频 mini
useLiveStore.subscribe((state, prev) => {
if (state.isActive && !prev.isActive && useVideoStore.getState().isActive) {
useVideoStore.getState().clearVideo();
}
});
}

View File

@@ -0,0 +1,74 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = 'play_progress';
// 持久化最多 200 条记录
const MAX_ENTRIES = 200;
// 视频接近结尾(>= 95%)视为看完,清掉记录
const FINISH_RATIO = 0.95;
interface ProgressEntry {
bvid: string;
time: number; // 秒
duration: number; // 秒
ts: number; // 写入时间戳
}
interface PlayProgressStore {
records: Record<string, ProgressEntry>;
hydrated: boolean;
hydrate: () => Promise<void>;
save: (bvid: string, time: number, duration: number) => void;
get: (bvid: string) => number; // 返回应跳转的秒数(无记录返回 0
clear: (bvid: string) => void;
}
const persist = (records: Record<string, ProgressEntry>) => {
// 控制总量:超量删最早
const entries = Object.values(records).sort((a, b) => b.ts - a.ts);
const trimmed = entries.slice(0, MAX_ENTRIES);
const trimmedMap: Record<string, ProgressEntry> = {};
trimmed.forEach(e => { trimmedMap[e.bvid] = e; });
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedMap)).catch(() => {});
return trimmedMap;
};
export const usePlayProgressStore = create<PlayProgressStore>((set, get) => ({
records: {},
hydrated: false,
hydrate: async () => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const records = JSON.parse(raw);
set({ records, hydrated: true });
return;
}
} catch {}
set({ hydrated: true });
},
save: (bvid, time, duration) => {
if (!bvid || !duration || duration <= 0) return;
// 视频接近结尾,清掉记录
if (time / duration >= FINISH_RATIO) {
get().clear(bvid);
return;
}
// 起始 5 秒不必记录
if (time < 5) return;
const records = { ...get().records };
records[bvid] = { bvid, time, duration, ts: Date.now() };
set({ records: persist(records) });
},
get: (bvid) => {
const r = get().records[bvid];
return r?.time ?? 0;
},
clear: (bvid) => {
const records = { ...get().records };
if (records[bvid]) {
delete records[bvid];
set({ records: persist(records) });
}
},
}));

43
store/playUrlCache.ts Normal file
View File

@@ -0,0 +1,43 @@
import { create } from 'zustand';
import type { PlayUrlResponse } from '../services/types';
interface CacheEntry {
playData: PlayUrlResponse;
mpdUri?: string;
ts: number;
}
interface PlayUrlCacheStore {
// 不通过 set 触发渲染(只是用 store 持有 Map
cache: Map<string, CacheEntry>;
get: (bvid: string, qn: number) => CacheEntry | undefined;
set: (bvid: string, qn: number, entry: Omit<CacheEntry, 'ts'>) => void;
}
const TTL_MS = 5 * 60 * 1000;
const MAX_ENTRIES = 30;
const makeKey = (bvid: string, qn: number) => `${bvid}_${qn}`;
export const usePlayUrlCache = create<PlayUrlCacheStore>((_set, getState) => ({
cache: new Map(),
get: (bvid, qn) => {
const map = getState().cache;
const entry = map.get(makeKey(bvid, qn));
if (!entry) return undefined;
if (Date.now() - entry.ts > TTL_MS) {
map.delete(makeKey(bvid, qn));
return undefined;
}
return entry;
},
set: (bvid, qn, entry) => {
const map = getState().cache;
map.set(makeKey(bvid, qn), { ...entry, ts: Date.now() });
// 简单 LRU超过容量删最早
if (map.size > MAX_ENTRIES) {
const oldestKey = map.keys().next().value;
if (oldestKey) map.delete(oldestKey);
}
},
}));

View File

@@ -0,0 +1,11 @@
import { create } from "zustand";
interface VisibleBigKeyState {
key: string | null;
setKey: (key: string | null) => void;
}
export const useVisibleBigKeyStore = create<VisibleBigKeyState>((set) => ({
key: null,
setKey: (key) => set({ key }),
}));

View File

@@ -5,13 +5,35 @@ import type { PlayUrlResponse, DashAudioItem } from '../services/types';
* 将 Bilibili DASH 响应写成 MPD 文件,返回 file:// URI 供 ExoPlayer 播放。
* 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。
*/
export async function buildDashMpdUri(playData: PlayUrlResponse, qn: number): Promise<string> {
export async function buildDashMpdUri(
playData: PlayUrlResponse,
qn: number,
bvid?: string,
): Promise<string> {
const xml = buildMpdXml(playData, qn);
const path = `${FileSystem.cacheDirectory}bili_dash_${qn}.mpd`;
// 带 bvid 区分,避免不同视频在同一 qn 上复用同一文件名导致命中过期 MPD
const suffix = bvid ? `${bvid}_${qn}` : String(qn);
const path = `${FileSystem.cacheDirectory}bili_dash_${suffix}.mpd`;
await FileSystem.writeAsStringAsync(path, xml, { encoding: FileSystem.EncodingType.UTF8 });
return path;
}
/** 删除指定视频的所有清晰度 MPD 缓存(详情页卸载时调用) */
export async function cleanupDashMpd(bvid: string): Promise<void> {
if (!bvid || !FileSystem.cacheDirectory) return;
try {
const files = await FileSystem.readDirectoryAsync(FileSystem.cacheDirectory);
const targets = files.filter(f => f.startsWith(`bili_dash_${bvid}_`) && f.endsWith('.mpd'));
await Promise.all(
targets.map(f =>
FileSystem.deleteAsync(`${FileSystem.cacheDirectory}${f}`, { idempotent: true }),
),
);
} catch {
// 忽略,缓存清理失败不影响功能
}
}
function isDolbyVision(codecs: string): boolean {
return /^(dvhe|dvh1)/.test(codecs);
}

9
utils/toast.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ToastAndroid, Platform, Alert } from "react-native";
export function toast(msg: string) {
if (Platform.OS === "android") {
ToastAndroid.show(msg, ToastAndroid.SHORT);
} else {
Alert.alert("", msg);
}
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "react";
import { Animated } from "react-native";
/**
* 控制底部 Sheet 的"挂载 + 滑入 / 滑出 + 卸载"时序。
*
* 用法:
* const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
* if (!rendered) return null;
* <Animated.View style={{ transform: [{ translateY: slideAnim }] }}>…</Animated.View>
*
* 关键行为:
* - visible=true 时立刻 rendered=true 并触发"由下往上"滑入
* - visible=false 时先反向滑出 240ms结束才把 rendered 置 false卸载 Modal
* - 中途反复切换 visible 不会错乱:被打断的 timing.start() 回调拿到的 finished=false
* 所以 rendered 不会在动画未完成时意外清掉。
*/
export function useSheetTransition(visible: boolean, sheetH: number) {
const [rendered, setRendered] = useState(visible);
const slideAnim = useRef(new Animated.Value(visible ? 0 : sheetH)).current;
useEffect(() => {
if (visible) {
setRendered(true);
Animated.timing(slideAnim, {
toValue: 0,
duration: 240,
useNativeDriver: true,
}).start();
} else {
Animated.timing(slideAnim, {
toValue: sheetH,
duration: 240,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) setRendered(false);
});
}
}, [visible, sheetH]);
return { rendered, slideAnim };
}

View File

@@ -1,4 +1,4 @@
import type { VideoItem, LiveRoom } from '../services/types';
import type { VideoItem } from '../services/types';
export interface NormalRow {
type: 'pair';
@@ -11,22 +11,14 @@ export interface BigRow {
item: VideoItem;
}
export interface LiveRow {
type: 'live';
left: LiveRoom;
right?: LiveRoom;
}
export type ListRow = NormalRow | BigRow;
export type ListRow = NormalRow | BigRow | LiveRow;
export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRow[] {
export function toListRows(pages: VideoItem[][]): ListRow[] {
const rows: ListRow[] = [];
let roomIdx = 0;
for (const chunk of pages) {
if (chunk.length === 0) continue;
// Highest view count becomes BigRow
let bigIdx = 0;
let maxView = chunk[0].stat?.view ?? 0;
for (let i = 1; i < chunk.length; i++) {
@@ -37,37 +29,18 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo
const bigItem = chunk[bigIdx];
const rest = chunk.filter((_, i) => i !== bigIdx);
const pairs: (NormalRow | LiveRow)[] = [];
const pairs: NormalRow[] = [];
for (let i = 0; i < rest.length; i += 2) {
if (rest[i + 1]) {
pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
}
}
if (liveRooms && liveRooms.length >= 2) {
const a = liveRooms[roomIdx % liveRooms.length];
const b = liveRooms[(roomIdx + 1) % liveRooms.length];
roomIdx += 2;
if (rows.length < 20) {
rows.push({ type: 'big', item: bigItem });
rows.push({ type: 'live', left: a, right: b });
rows.push(...pairs);
} else {
rows.push(...pairs);
rows.push({ type: 'big', item: bigItem });
rows.push({ type: 'live', left: a, right: b });
}
} else {
// No live data, fall back to original logic
if (rows.length < 20) {
rows.push({ type: 'big', item: bigItem }, ...pairs);
} else {
rows.push(...pairs, { type: 'big', item: bigItem });
}
}
}
return rows;
}