diff --git a/app/_layout.tsx b/app/_layout.tsx
index 3058fb9..4dd43d7 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -8,6 +8,7 @@ import { useDownloadStore } from '../store/downloadStore';
import { useSettingsStore } from '../store/settingsStore';
import { useTheme } from '../utils/theme';
import { MiniPlayer } from '../components/MiniPlayer';
+import { LiveMiniPlayer } from '../components/LiveMiniPlayer';
import * as Sentry from '@sentry/react-native';
import { ErrorBoundary } from '@sentry/react-native';
@@ -80,6 +81,7 @@ function RootLayout() {
+
);
diff --git a/app/live/[roomId].tsx b/app/live/[roomId].tsx
index 9881f34..f0652b8 100644
--- a/app/live/[roomId].tsx
+++ b/app/live/[roomId].tsx
@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useState, useEffect } from "react";
import {
View,
Text,
@@ -18,6 +18,7 @@ import DanmakuList from "../../components/DanmakuList";
import { formatCount } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { useTheme } from "../../utils/theme";
+import { useLiveStore } from "../../store/liveStore";
type Tab = "intro" | "danmaku";
@@ -36,6 +37,15 @@ export default function LiveDetailScreen() {
const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0;
+ const { roomId: miniRoomId, setLive, clearLive } = useLiveStore();
+
+ // 进入该直播间时,若小窗正在播放同一房间,清除小窗避免双播
+ useEffect(() => {
+ if (miniRoomId === id) {
+ clearLive();
+ }
+ }, [id, miniRoomId]);
+
const actualRoomId = room?.roomid ?? id;
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
@@ -49,6 +59,19 @@ export default function LiveDetailScreen() {
{room?.title ?? "直播间"}
+ {isLive && hlsUrl ? (
+ {
+ setLive(id, room?.title ?? '', room?.keyframe ?? '', hlsUrl);
+ router.back();
+ }}
+ >
+
+
+ ) : (
+
+ )}
{/* Player */}
@@ -177,6 +200,7 @@ const styles = StyleSheet.create({
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
+ pipBtn: { padding: 4, width: 32, alignItems: 'center' },
topTitle: {
flex: 1,
fontSize: 15,
diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx
new file mode 100644
index 0000000..50afa82
--- /dev/null
+++ b/components/LiveMiniPlayer.tsx
@@ -0,0 +1,185 @@
+import React, { useRef } from 'react';
+import {
+ View,
+ Text,
+ Image,
+ StyleSheet,
+ TouchableOpacity,
+ Animated,
+ PanResponder,
+ Dimensions,
+ Platform,
+} from 'react-native';
+import { useRouter } from 'expo-router';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { Ionicons } from '@expo/vector-icons';
+import { useLiveStore } from '../store/liveStore';
+import { useVideoStore } from '../store/videoStore';
+import { proxyImageUrl } from '../utils/imageUrl';
+
+const MINI_W = 160;
+const MINI_H = 90;
+
+const LIVE_HEADERS = {
+ Referer: 'https://live.bilibili.com',
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+};
+
+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 panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onPanResponderGrant: () => {
+ pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
+ pan.setValue({ x: 0, y: 0 });
+ },
+ onPanResponderMove: Animated.event(
+ [null, { dx: pan.x, dy: pan.y }],
+ { useNativeDriver: false },
+ ),
+ onPanResponderRelease: () => {
+ pan.flattenOffset();
+ const { width: sw, height: sh } = Dimensions.get('window');
+ const curX = (pan.x as any)._value;
+ const curY = (pan.y as any)._value;
+ const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX));
+ const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
+ if (curX !== clampedX || curY !== clampedY) {
+ Animated.spring(pan, {
+ toValue: { x: clampedX, y: clampedY },
+ useNativeDriver: false,
+ }).start();
+ }
+ },
+ }),
+ ).current;
+
+ if (!isActive) return null;
+
+ // 视频 MiniPlayer 激活时,直播小窗叠放其上方(避免重叠)
+ const bottomOffset = insets.bottom + 16 + (videoMiniActive ? 106 : 0);
+
+ const handlePress = () => {
+ router.push(`/live/${roomId}` as any);
+ };
+
+ // Web 端降级:展示封面图 + LIVE 徽标
+ if (Platform.OS === 'web') {
+ return (
+
+
+
+
+
+ LIVE
+
+ {title}
+
+
+
+
+
+ );
+ }
+
+ // Native:实际 HLS 流播放
+ const Video = require('react-native-video').default;
+
+ return (
+
+
+
+
+
+
+
+ LIVE
+
+ {title}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ right: 12,
+ width: MINI_W,
+ height: MINI_H,
+ borderRadius: 8,
+ backgroundColor: '#1a1a1a',
+ overflow: 'hidden',
+ elevation: 8,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.3,
+ shadowRadius: 4,
+ },
+ main: { flex: 1 },
+ videoArea: {
+ width: '100%',
+ height: 66,
+ backgroundColor: '#111',
+ },
+ liveBadge: {
+ position: 'absolute',
+ top: 4,
+ left: 6,
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.55)',
+ paddingHorizontal: 5,
+ paddingVertical: 2,
+ borderRadius: 3,
+ gap: 3,
+ },
+ liveDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#f00' },
+ liveText: { color: '#fff', fontSize: 9, fontWeight: '700', letterSpacing: 0.5 },
+ titleText: {
+ color: '#fff',
+ fontSize: 11,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ lineHeight: 14,
+ height: 24,
+ backgroundColor: '#1a1a1a',
+ },
+ closeBtn: {
+ position: 'absolute',
+ top: 4,
+ right: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 9,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/store/liveStore.ts b/store/liveStore.ts
new file mode 100644
index 0000000..444b49f
--- /dev/null
+++ b/store/liveStore.ts
@@ -0,0 +1,23 @@
+import { create } from 'zustand';
+
+interface LiveStore {
+ isActive: boolean;
+ roomId: number;
+ title: string;
+ cover: string; // room.keyframe(直播截图)用于 web 端降级封面
+ hlsUrl: string;
+ setLive: (roomId: number, title: string, cover: string, hlsUrl: string) => void;
+ clearLive: () => void;
+}
+
+export const useLiveStore = create(set => ({
+ isActive: false,
+ roomId: 0,
+ title: '',
+ cover: '',
+ hlsUrl: '',
+ setLive: (roomId, title, cover, hlsUrl) =>
+ set({ isActive: true, roomId, title, cover, hlsUrl }),
+ clearLive: () =>
+ set({ isActive: false, roomId: 0, title: '', cover: '', hlsUrl: '' }),
+}));