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: '' }), +}));