banner
约 300 字
1 分钟

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 创建项目并安装必要依赖:

bash
# 创建项目
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):

gradle
dependencies {
    implementation 'org.jupnp:org.jupnp:2.7.0'        // UPnP 协议栈
    implementation 'org.jupnp:org.jupnp.support:2.7.0' // UPnP 支持库
}

iOS (Podfile):

Ruby
pod 'CocoaUPnP', '~> 0.2.0'

二、设备发现模块

SSDP 发现是 DLNA 投屏的第一步,需要扫描局域网内的 DLNA 设备。

SSDP 发现消息

TypeScript
// 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 组播,需要通过原生模块封装:

TypeScript
// 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 协议栈实现设备发现:

Java
// 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 控制

TypeScript
// 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

TypeScript
// 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,通常采用底部弹出式面板设计。

设备选择组件

TSX
// 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>
  );
}

五、投屏控制页面

当投屏建立后,需要一个专属控制页面来管理播放状态。

投屏控制页

TSX
// 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>
  );
}

六、后台保活与通知

投屏过程中,应用可能被用户切到后台或被系统回收。需要实现通知保活机制。

通知保活服务

TypeScript
// 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();
}

心跳保活

当通知被系统意外删除时,需要自动恢复:

TypeScript
// 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]);
}

七、悬浮按钮

投屏中时,在屏幕右下角显示一个悬浮按钮,方便用户快速回到控制页。

TSX
// 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+ 对附近设备扫描有严格权限要求:

TypeScript
// 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>
  );
}

九、完整使用示例

TSX
// 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>
  );
}

十、性能优化与最佳实践

设备发现优化

  1. 缓存已知设备:将之前发现过的设备信息缓存,下次直接展示,减少用户等待时间

  2. 渐进式加载:找到一个设备就立即展示,不用等全部搜完

  3. 设置超时:搜索超过 10 秒未找到设备时自动停止并提示用户

投屏稳定性

  1. mountedRef 守卫:所有异步回调中检查组件是否已卸载,防止状态更新导致的闪退

  2. 心跳机制:15 秒轮询通知 + 进度查询,确保应用即使在前台也能感知设备状态

  3. 错误恢复:设备断开时自动停止投屏并回到播放页续播

网络适配

  1. 自动检测网络:检查手机是否和电视在同一局域网

  2. URL 兼容性:为 M3U8 URL 自动补充 Referer / Origin 请求头,处理 403 错误

  3. 多协议支持:除 HTTP 外也支持 RTMP、HLS 等流媒体协议

UI/UX

  1. 连接状态可视化:设备图标周围用彩色圆环包裹,绿色表示在线

  2. 触控反馈:点按背景可关闭设备选择面板

  3. 横竖屏适配:全屏模式下沉浸式控制界面,支持旋转锁定


十一、常见问题

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 生成并发布。

END

相关文章

暂无相关文章