React Native 实现 DLNA 投屏功能完整指南
摘要
完整详细指南:在 React Native (Expo) 应用中实现 DLNA/UPnP 投屏功能,包括设备发现、投屏控制、通知保活等核心模块。
React Native 实现 DLNA 投屏功能完整指南
概述
DLNA(Digital Living Network Alliance)基于 UPnP 协议,允许同一局域网内的设备之间共享媒体内容。在 React Native 应用中实现 DLNA 投屏功能,可以让移动端应用将视频、音频等内容投射到智能电视、机顶盒等大屏设备上播放。
本指南介绍如何在 React Native (Expo) 应用中完整实现 DLNA/UPnP 投屏功能,包括设备发现、连接控制、播放管理、后台保活等核心模块。
技术架构
┌─────────────────────────────────────────────────┐
│ React Native 应用 │
│ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ 设备发现 │ │ 投屏控制 │ │ 播放器 │ │
│ │ (SSDP) │→ │ (AVT/GR) │→ │ (Expo AV) │ │
│ └──────────┘ └───────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Native Module 层 │ │
│ │ (Android UPnP / CocoaUPnP) │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
│
▼
┌──────────────────┐
│ TV / 投屏设备 │
│ (DLNA Renderer) │
└──────────────────┘核心技术点
DLNA/UPnP 协议栈
DLNA 投屏基于以下核心协议:
协议 | 全称 | 用途 |
|---|---|---|
SSDP | Simple Service Discovery Protocol | 局域网设备发现(组播 239.255.255.190:1900) |
UPnP AVT | AVTransport Service | 控制播放状态(播放/暂停/停止/跳转) |
UPnP GR | AVTransport RenderingControl | 控制音量、静音等渲染参数 |
UPnP CM | ConnectionManager | 管理连接和媒体传输 |
SOAP | Simple Object Access Protocol | 服务调用协议(XML 格式) |
设备角色
DMS (Digital Media Server) — 提供媒体内容源
DMP (Digital Media Player) — 查找并播放内容
DMR (Digital Media Renderer) — 渲染媒体内容(通常就是电视)
投屏场景下,手机作为 DMP + 控制点,电视作为 DMR。
一、项目初始化
使用 Expo 创建项目并安装必要依赖:
# 创建项目
npx create-expo-app dlna-demo --template blank-typescript
cd dlna-demo
# 安装核心依赖
npx expo install expo-av # 视频播放
npx expo install expo-keep-awake # 屏幕常亮
npx expo install expo-notifications # 后台通知
npx expo install expo-network # 网络状态检测
npx expo install expo-location # 附近设备权限原生模块依赖
由于 DLNA/UPnP 需要原生网络通信(SSDP 组播、SOAP 请求),需要在原生项目中添加以下支持:
Android (android/app/build.gradle):
dependencies {
implementation 'org.jupnp:org.jupnp:2.7.0' // UPnP 协议栈
implementation 'org.jupnp:org.jupnp.support:2.7.0' // UPnP 支持库
}iOS (Podfile):
pod 'CocoaUPnP', '~> 0.2.0'二、设备发现模块
SSDP 发现是 DLNA 投屏的第一步,需要扫描局域网内的 DLNA 设备。
SSDP 发现消息
// services/ssdp-discovery.ts
export interface DLNADevice {
usn: string; // 设备唯一标识
friendlyName: string; // 设备名称(如 "客厅的电视")
deviceType: string; // 设备类型(如 MediaRenderer)
location: string; // 设备描述 XML 地址
ip: string; // 设备 IP 地址
port: number; // 设备端口
iconUrl?: string; // 设备图标
isActive: boolean; // 是否在线
}
// SSDP M-SEARCH 请求报文
const SSDP_MSEARCH =
'M-SEARCH * HTTP/1.1\r\n' +
'HOST: 239.255.255.250:1900\r\n' +
'MAN: "ssdp:discover"\r\n' +
'MX: 3\r\n' +
'ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n';设备发现实现
由于 React Native 的 JavaScript 环境无法直接发送 UDP 组播,需要通过原生模块封装:
// hooks/useDeviceDiscovery.ts
import { useState, useEffect, useRef, useCallback } from 'react';
import { Platform, NativeModules } from 'react-native';
const { UPnPDiscovery } = NativeModules;
export function useDeviceDiscovery() {
const [devices, setDevices] = useState<DLNADevice[]>([]);
const [isSearching, setIsSearching] = useState(false);
const mountedRef = useRef(true);
const startDiscovery = useCallback(async () => {
setIsSearching(true);
setDevices([]);
try {
// 检查附近设备权限(Android 12+ 需要)
if (Platform.OS === 'android' && Platform.Version >= 31) {
// BLUETOOTH_SCAN / BLUETOOTH_CONNECT / ACCESS_FINE_LOCATION
}
// 启动原生 UPnP 设备发现
UPnPDiscovery.startDiscovery((device: DLNADevice) => {
if (!mountedRef.current) return;
setDevices(prev => {
// 避免重复添加
const exists = prev.find(d => d.usn === device.usn);
if (exists) {
return prev.map(d =>
d.usn === device.usn ? { ...d, isActive: true } : d
);
}
return [...prev, device];
});
});
} catch (error) {
console.error('Device discovery failed:', error);
} finally {
setIsSearching(false);
}
}, []);
const stopDiscovery = useCallback(() => {
UPnPDiscovery.stopDiscovery();
}, []);
// 清理
useEffect(() => {
return () => {
mountedRef.current = false;
stopDiscovery();
};
}, [stopDiscovery]);
return {
devices,
isSearching,
startDiscovery,
stopDiscovery,
};
}原生 Android UPnP 发现
Android 端使用 jUPnP 协议栈实现设备发现:
// android/app/src/main/java/com/dlnademo/UPnPDiscoveryModule.java
public class UPnPDiscoveryModule extends ReactContextBaseJavaModule {
private Registry registry;
private UpnpService upnpService;
@ReactMethod
public void startDiscovery(DeviceCallback callback) {
upnpService = new UpnpServiceImpl();
registry = upnpService.getRegistry();
registry.addListener(new DefaultRegistryListener() {
@Override
public void remoteDeviceAdded(Registry registry, RemoteDevice device) {
if (device.getType().getType().equals("MediaRenderer")) {
WritableMap map = Arguments.createMap();
map.putString("usn", device.getIdentity().getUdn().getIdentifierString());
map.putString("friendlyName", device.getDetails().getFriendlyName());
map.putString("location", device.getDetails().getBaseURL().toString());
map.putString("ip", device.getIdentity().getDescriptorURL().getHost());
map.putInt("port", device.getIdentity().getDescriptorURL().getPort());
callback.invoke(map);
}
}
});
}
}三、投屏控制模块
发现设备后,需要实现基于 UPnP AVTransport 和 RenderingControl 服务的投屏控制。
AVTransport Service 控制
// services/casting-service.ts
import { NativeModules } from 'react-native';
const { UPnPCasting } = NativeModules;
export interface CastingControl {
// 设置媒体源并开始播放
setAVTransportURI: (
deviceUSN: string,
mediaUrl: string,
metadata?: string
) => Promise<void>;
// 播放控制
play: (deviceUSN: string, speed?: string) => Promise<void>;
pause: (deviceUSN: string) => Promise<void>;
stop: (deviceUSN: string) => Promise<void>;
// 进度控制
seek: (deviceUSN: string, positionSeconds: number) => Promise<void>;
getPositionInfo: (deviceUSN: string) => Promise<PositionInfo>;
// 音量控制
setVolume: (deviceUSN: string, volume: number) => Promise<void>;
getVolume: (deviceUSN: string) => Promise<number>;
}
export interface PositionInfo {
trackDuration: number; // 总时长(秒)
relTime: number; // 当前进度(秒)
trackURI: string; // 当前播放的媒体 URL
trackMetaData: string; // 元数据
}
// SOAP 请求示例(AVTransport:Play)
const PLAY_SOAP_BODY = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>1</Speed>
</u:Play>
</s:Body>
</s:Envelope>`;投屏控制 Hook
// hooks/useCasting.ts
import { useState, useCallback, useRef } from 'react';
import { Platform, NativeModules } from 'react-native';
const { UPnPCasting } = NativeModules;
export interface CastingState {
isCasting: boolean;
deviceName: string;
position: number;
duration: number;
isPlaying: boolean;
}
export function useCasting() {
const [state, setState] = useState<CastingState>({
isCasting: false,
deviceName: '',
position: 0,
duration: 0,
isPlaying: false,
});
const heartbeatRef = useRef<ReturnType<typeof setInterval>>();
const currentDeviceRef = useRef<string>('');
const startCasting = useCallback(async (
deviceUSN: string,
deviceName: string,
mediaUrl: string,
title?: string
) => {
currentDeviceRef.current = deviceUSN;
// 构造媒体元数据(可选)
const metadata = title
? `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
<item id="1" parentID="0" restricted="0">
<dc:title>${title}</dc:title>
<res>${mediaUrl}</res>
</item>
</DIDL-Lite>`
: '';
await UPnPCasting.setAVTransportURI(deviceUSN, mediaUrl, metadata);
await UPnPCasting.play(deviceUSN);
setState(prev => ({
...prev,
isCasting: true,
deviceName,
isPlaying: true,
}));
// 启动进度轮询
startPositionPolling(deviceUSN);
}, []);
const togglePlayPause = useCallback(async () => {
if (!currentDeviceRef.current) return;
if (state.isPlaying) {
await UPnPCasting.pause(currentDeviceRef.current);
} else {
await UPnPCasting.play(currentDeviceRef.current);
}
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }));
}, [state.isPlaying]);
const seekTo = useCallback(async (seconds: number) => {
if (!currentDeviceRef.current) return;
await UPnPCasting.seek(currentDeviceRef.current, seconds);
setState(prev => ({ ...prev, position: seconds }));
}, []);
const stopCasting = useCallback(async () => {
if (!currentDeviceRef.current) return;
stopPositionPolling();
await UPnPCasting.stop(currentDeviceRef.current);
setState({
isCasting: false,
deviceName: '',
position: 0,
duration: 0,
isPlaying: false,
});
currentDeviceRef.current = '';
}, []);
// 定期轮询进度
const startPositionPolling = (deviceUSN: string) => {
stopPositionPolling();
heartbeatRef.current = setInterval(async () => {
try {
const info = await UPnPCasting.getPositionInfo(deviceUSN);
setState(prev => ({
...prev,
position: info.relTime,
duration: info.trackDuration,
}));
} catch {
// 设备断开
stopCasting();
}
}, 5000); // 5 秒轮询一次
};
const stopPositionPolling = () => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined;
}
};
return {
...state,
startCasting,
togglePlayPause,
seekTo,
stopCasting,
updatePosition: (pos: number) => setState(p => ({ ...p, position: pos })),
};
}四、设备选择 UI
设备选择面板是投屏的入口 UI,通常采用底部弹出式面板设计。
设备选择组件
// components/DevicePicker.tsx
import React, { useCallback } from 'react';
import {
View,
Text,
TouchableOpacity,
FlatList,
Modal,
ActivityIndicator,
StyleSheet,
} from 'react-native';
import { useDeviceDiscovery, DLNADevice } from '../hooks/useDeviceDiscovery';
interface DevicePickerProps {
visible: boolean;
onClose: () => void;
onSelectDevice: (device: DLNADevice) => void;
}
export function DevicePicker({
visible,
onClose,
onSelectDevice,
}: DevicePickerProps) {
const { devices, isSearching, startDiscovery } = useDeviceDiscovery();
// 面板打开时自动搜索
React.useEffect(() => {
if (visible) {
startDiscovery();
}
}, [visible, startDiscovery]);
const handleDevicePress = useCallback((device: DLNADevice) => {
onSelectDevice(device);
onClose();
}, [onSelectDevice, onClose]);
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
>
<View style={styles.sheet}>
<Text style={styles.title}>选择投屏设备</Text>
{/* 搜索状态 */}
{isSearching && (
<View style={styles.searching}>
<ActivityIndicator size="small" color="#3B82F6" />
<Text style={styles.searchingText}>正在搜索设备...</Text>
</View>
)}
{/* 设备列表 */}
<FlatList
data={devices.filter(d => d.isActive)}
keyExtractor={(item) => item.usn}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.deviceItem}
onPress={() => handleDevicePress(item)}
>
<View style={styles.deviceIcon}>
<Text>📺</Text>
</View>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.friendlyName}</Text>
<Text style={styles.deviceIP}>{item.ip}</Text>
</View>
<View style={styles.connectBadge}>
<Text style={styles.connectText}>投屏</Text>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
<Text style={styles.emptyText}>
未发现设备,请确保电视已开机并连接同一网络
</Text>
}
/>
{/* 刷新按钮 */}
<TouchableOpacity
style={styles.refreshBtn}
onPress={startDiscovery}
disabled={isSearching}
>
<Text style={styles.refreshText}>
{isSearching ? '搜索中...' : '重新搜索'}
</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
);
}五、投屏控制页面
当投屏建立后,需要一个专属控制页面来管理播放状态。
投屏控制页
// screens/CastingControlScreen.tsx
import React, { useCallback, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
SafeAreaView,
StatusBar,
} from 'react-native';
import Slider from '@react-native-community/slider';
import { useCasting } from '../hooks/useCasting';
interface CastingControlProps {
mediaTitle: string;
onStop: () => void;
onClose: () => void;
onSwitchSource?: () => void;
}
export function CastingControlScreen({
mediaTitle,
onStop,
onClose,
onSwitchSource,
}: CastingControlProps) {
const {
isPlaying,
position,
duration,
togglePlayPause,
seekTo,
setVolume,
getVolume,
} = useCasting();
const [volume, setVolumeState] = React.useState(50);
// 加载音量
useEffect(() => {
getVolume().then(setVolumeState).catch(() => {});
}, []);
const formatTime = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
};
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
{/* 顶部栏 */}
<View style={styles.header}>
<TouchableOpacity onPress={onClose}>
<Text style={styles.closeBtn}>收起 ↑</Text>
</TouchableOpacity>
<Text style={styles.title} numberOfLines={1}>
{mediaTitle}
</Text>
<TouchableOpacity onPress={onStop}>
<Text style={styles.stopBtn}>⏹</Text>
</TouchableOpacity>
</View>
{/* 进度条 */}
<View style={styles.progressSection}>
<Text style={styles.currentTime}>{formatTime(position)}</Text>
<Slider
style={styles.slider}
minimumValue={0}
maximumValue={duration}
value={position}
onSlidingComplete={seekTo}
minimumTrackTintColor="#3B82F6"
maximumTrackTintColor="#4B5563"
thumbTintColor="#3B82F6"
/>
<Text style={styles.totalTime}>{formatTime(duration)}</Text>
</View>
{/* 控制按钮 */}
<View style={styles.controls}>
<TouchableOpacity
style={styles.controlBtn}
onPress={togglePlayPause}
>
<Text style={styles.controlIcon}>
{isPlaying ? '⏸' : '▶️'}
</Text>
</TouchableOpacity>
</View>
{/* 音量控制 */}
<View style={styles.volumeSection}>
<Text style={styles.volumeLabel}>🔉</Text>
<Slider
style={styles.volumeSlider}
minimumValue={0}
maximumValue={100}
value={volume}
onValueChange={setVolumeState}
onSlidingComplete={setVolume}
minimumTrackTintColor="#10B981"
maximumTrackTintColor="#4B5563"
thumbTintColor="#10B981"
/>
<Text style={styles.volumeLabel}>🔊</Text>
</View>
{/* 切换源 */}
{onSwitchSource && (
<TouchableOpacity
style={styles.switchBtn}
onPress={onSwitchSource}
>
<Text style={styles.switchText}>更换</Text>
</TouchableOpacity>
)}
</SafeAreaView>
);
}六、后台保活与通知
投屏过程中,应用可能被用户切到后台或被系统回收。需要实现通知保活机制。
通知保活服务
// services/casting-notification.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function setupCastingNotification() {
// 请求通知权限
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
// 配置通知通道(Android)
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('casting', {
name: '投屏控制',
importance: Notifications.AndroidImportance.LOW,
vibrationPattern: null,
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
});
}
}
export async function showCastingNotification(mediaTitle: string) {
await Notifications.scheduleNotificationAsync({
content: {
title: '正在投屏',
body: `${mediaTitle}`,
data: { screen: 'casting' },
sticky: true, // 不可滑掉
autoDismiss: false,
...(Platform.OS === 'android' && {
channelId: 'casting',
color: '#10B981',
}),
},
trigger: null, // 立即显示
});
}
export async function dismissCastingNotification() {
await Notifications.dismissAllNotificationsAsync();
}心跳保活
当通知被系统意外删除时,需要自动恢复:
// hooks/useCastingKeepAlive.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import {
showCastingNotification,
} from '../services/casting-notification';
export function useCastingKeepAlive(
isCasting: boolean,
mediaTitle: string
) {
const heartBeatRef = useRef<ReturnType<typeof setInterval>>();
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
if (!isCasting) {
if (heartBeatRef.current) {
clearInterval(heartBeatRef.current);
heartBeatRef.current = undefined;
}
return;
}
// 15 秒心跳:重新弹出通知(防止被系统删除)
heartBeatRef.current = setInterval(() => {
showCastingNotification(mediaTitle);
}, 15000);
// 监听前后台切换
const subscription = AppState.addEventListener('change', (nextState) => {
if (
appStateRef.current === 'background' &&
nextState === 'active'
) {
// 回到前台时刷新通知
if (isCasting) {
showCastingNotification(mediaTitle);
}
}
appStateRef.current = nextState;
});
return () => {
if (heartBeatRef.current) {
clearInterval(heartBeatRef.current);
}
subscription.remove();
};
}, [isCasting, mediaTitle]);
}七、悬浮按钮
投屏中时,在屏幕右下角显示一个悬浮按钮,方便用户快速回到控制页。
// components/CastingFloatingButton.tsx
import React from 'react';
import { TouchableOpacity, StyleSheet, Animated } from 'react-native';
interface CastingFloatingButtonProps {
visible: boolean;
onPress: () => void;
}
export function CastingFloatingButton({
visible,
onPress,
}: CastingFloatingButtonProps) {
const scale = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.spring(scale, {
toValue: visible ? 1 : 0,
useNativeDriver: true,
friction: 6,
}).start();
}, [visible, scale]);
if (!visible) return null;
return (
<Animated.View style={[styles.container, { transform: [{ scale }] }]}>
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.8}
>
<Animated.Text style={styles.icon}>📺</Animated.Text>
</TouchableOpacity>
</Animated.View>
);
}八、权限处理
Android 12+ 对附近设备扫描有严格权限要求:
// utils/permissions.ts
import { Platform, PermissionsAndroid } from 'react-native';
export async function requestNearbyDevicesPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version >= 31) {
// Android 12+:BLUETOOTH_SCAN + BLUETOOTH_CONNECT
const grants = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
]);
return Object.values(grants).every(
(status) => status === PermissionsAndroid.RESULTS.GRANTED
);
} else {
// Android 10-11:需要位置权限
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
}
}
// 权限被拒时显示重新申请按钮
export function PermissionDeniedBanner({
onRetry,
}: {
onRetry: () => void;
}) {
return (
<View style={styles.banner}>
<Text style={styles.bannerText}>
需要附近设备权限才能搜索投屏设备
</Text>
<TouchableOpacity onPress={onRetry}>
<Text style={styles.retryBtn}>重新申请</Text>
</TouchableOpacity>
</View>
);
}九、完整使用示例
// App.tsx
import React, { useState, useCallback } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { DevicePicker } from './components/DevicePicker';
import { CastingControlScreen } from './screens/CastingControlScreen';
import { CastingFloatingButton } from './components/CastingFloatingButton';
import { useCasting } from './hooks/useCasting';
import { useCastingKeepAlive } from './hooks/useCastingKeepAlive';
import {
setupCastingNotification,
dismissCastingNotification,
} from './services/casting-notification';
import {
requestNearbyDevicesPermission,
} from './utils/permissions';
export default function App() {
const [pickerVisible, setPickerVisible] = useState(false);
const [controlVisible, setControlVisible] = useState(false);
const casting = useCasting();
// 启动通知服务
React.useEffect(() => {
setupCastingNotification();
}, []);
// 投屏通知保活
useCastingKeepAlive(casting.isCasting, '当前播放内容');
// 选择设备后开始投屏
const handleSelectDevice = useCallback(async (device) => {
await casting.startCasting(
device.usn,
device.friendlyName,
'https://example.com/video.mp4',
'示例视频标题'
);
setControlVisible(true);
}, [casting]);
// 停止投屏
const handleStopCasting = useCallback(async () => {
await casting.stopCasting();
await dismissCastingNotification();
setControlVisible(false);
}, [casting]);
return (
<View style={{ flex: 1 }}>
{/* 视频播放页 / 内容页 */}
<TouchableOpacity onPress={() => setPickerVisible(true)}>
<Text>投屏到电视</Text>
</TouchableOpacity>
{/* 设备选择面板 */}
<DevicePicker
visible={pickerVisible}
onClose={() => setPickerVisible(false)}
onSelectDevice={handleSelectDevice}
/>
{/* 投屏控制页 */}
{controlVisible && (
<CastingControlScreen
mediaTitle="示例视频"
onStop={handleStopCasting}
onClose={() => setControlVisible(false)}
/>
)}
{/* 投屏悬浮按钮 */}
<CastingFloatingButton
visible={casting.isCasting && !controlVisible}
onPress={() => setControlVisible(true)}
/>
</View>
);
}十、性能优化与最佳实践
设备发现优化
缓存已知设备:将之前发现过的设备信息缓存,下次直接展示,减少用户等待时间
渐进式加载:找到一个设备就立即展示,不用等全部搜完
设置超时:搜索超过 10 秒未找到设备时自动停止并提示用户
投屏稳定性
mountedRef 守卫:所有异步回调中检查组件是否已卸载,防止状态更新导致的闪退
心跳机制:15 秒轮询通知 + 进度查询,确保应用即使在前台也能感知设备状态
错误恢复:设备断开时自动停止投屏并回到播放页续播
网络适配
自动检测网络:检查手机是否和电视在同一局域网
URL 兼容性:为 M3U8 URL 自动补充 Referer / Origin 请求头,处理 403 错误
多协议支持:除 HTTP 外也支持 RTMP、HLS 等流媒体协议
UI/UX
连接状态可视化:设备图标周围用彩色圆环包裹,绿色表示在线
触控反馈:点按背景可关闭设备选择面板
横竖屏适配:全屏模式下沉浸式控制界面,支持旋转锁定
十一、常见问题
Q:为什么搜不到电视?
确保手机和电视连接同一个 WiFi
检查路由器是否开启了 AP 隔离(Guest Network)
Android 12+ 需要授予"附近设备"权限
部分电视需要手动开启 DLNA 服务(设置在"网络"或"外接设备"中)
Q:投屏后播放卡顿?
检查 WiFi 信号强度
尝试降低视频码率
M3U8 流建议使用本地代理过滤广告片段
Q:应用切后台后投屏断开?
实现通知保活机制(START_STICKY + 心跳)
检查应用是否被系统省电策略限制
引导用户在系统设置中添加白名单
Q:投屏后没有声音?
确认电视 DLNA 渲染器支持音频格式
尝试不同的媒体格式
技术栈总结
模块 | 推荐技术方案 |
|---|---|
框架 | React Native + Expo (SDK 51+) |
播放器 | expo-av |
UPnP 协议栈 | jUPnP (Android) / CocoaUPnP (iOS) |
通知 | expo-notifications |
屏幕常亮 | expo-keep-awake |
网络检测 | expo-network |
悬浮按钮 | React Native Animated |
进度滑块 | @react-native-community/slider |
本文档由 MClaw 生成并发布。
相关文章
暂无相关文章
