基础入门 React Native 鸿蒙跨平台开发:Video 全屏播放与画中画 鸿蒙实战

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

一、核心知识点

全屏播放和画中画(Picture-in-Picture, PiP)是视频播放器的核心功能,能够提升用户观看体验。在鸿蒙端,react-native-video 提供了完善的全屏和画中画支持,让开发者可以轻松实现专业级的视频播放功能。

全屏播放核心概念

typescript 复制代码
import Video, { VideoRef } from 'react-native-video';
import { StatusBar, SafeAreaView } from 'react-native';

// 全屏播放基础实现
const FullscreenVideo = () => {
  const [isFullscreen, setIsFullscreen] = useState<boolean>(false);

  return (
    <SafeAreaView style={[styles.container, isFullscreen && styles.fullscreen]}>
      <StatusBar hidden={isFullscreen} />
      <Video
        source={{ uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4' }}
        style={[styles.video, isFullscreen && styles.fullscreenVideo]}
        resizeMode="contain"
      />
    </SafeAreaView>
  );
};

画中画核心概念

typescript 复制代码
// 画中画基础实现
const PictureInPictureVideo = () => {
  const [isPictureInPicture, setIsPictureInPicture] = useState<boolean>(false);
  const [enterPictureInPictureOnLeave, setEnterPictureInPictureOnLeave] = useState<boolean>(true);

  return (
    <Video
      source={{ uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4' }}
      enterPictureInPictureOnLeave={enterPictureInPictureOnLeave}
      onPictureInPictureStatusChanged={(e) => {
        setIsPictureInPicture(e.isActive || false);
      }}
    />
  );
};

全屏与画中画模式对比

视频播放模式
全屏播放
画中画播放
嵌入式播放
隐藏状态栏
视频占据全屏
沉浸式体验
小窗口悬浮
可拖动调整
后台持续播放
固定尺寸容器
与其他内容共存
页面内嵌显示


二、实战核心代码解析

1. 全屏播放切换

typescript 复制代码
// 全屏播放切换
const ToggleFullscreenPlayer = () => {
  const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
  const videoRef = useRef<VideoRef>(null);

  const toggleFullscreen = () => {
    setIsFullscreen(prev => !prev);
  };

  return (
    <View style={styles.container}>
      <TouchableOpacity
        onPress={toggleFullscreen}
        style={styles.fullscreenButton}
      >
        <Video
          ref={videoRef}
          source={{ uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4' }}
          style={[styles.video, isFullscreen && styles.fullscreenVideo]}
          resizeMode="contain"
          paused={false}
          repeat={true}
        />
        <Text style={styles.fullscreenButtonText}>
          {isFullscreen ? '退出全屏' : '全屏播放'}
        </Text>
      </TouchableOpacity>
    </View>
  );
};

2. 画中画模式

typescript 复制代码
// 画中画播放器
const PictureInPicturePlayer = () => {
  const [isPictureInPicture, setIsPictureInPicture] = useState<boolean>(false);
  const [enterPictureInPictureOnLeave, setEnterPictureInPictureOnLeave] = useState<boolean>(true);
  const videoRef = useRef<VideoRef>(null);

  const handleEnterPictureInPicture = () => {
    videoRef.current?.enterPictureInPicture();
    setIsPictureInPicture(true);
  };

  const handleExitPictureInPicture = () => {
    videoRef.current?.exitPictureInPicture();
    setIsPictureInPicture(false);
  };

  return (
    <View style={styles.container}>
      <View style={styles.controls}>
        <Text style={styles.controlLabel}>
          应用返回桌面时自动启动画中画:
        </Text>
        <TouchableOpacity
          style={[styles.switch, enterPictureInPictureOnLeave && styles.switchActive]}
          onPress={() => setEnterPictureInPictureOnLeave(prev => !prev)}
        >
          <Text style={[styles.switchText, enterPictureInPictureOnLeave && styles.switchTextActive]}>
            {enterPictureInPictureOnLeave ? '开' : '关'}
          </Text>
        </TouchableOpacity>
      </View>

      <View style={styles.buttonContainer}>
        <TouchableOpacity
          style={[styles.pipButton, isPictureInPicture && styles.pipButtonActive]}
          onPress={handleEnterPictureInPicture}
          disabled={isPictureInPicture}
        >
          <Text style={styles.pipButtonText}>开启画中画</Text>
        </TouchableOpacity>
        <TouchableOpacity
          style={[styles.pipButton, !isPictureInPicture && styles.pipButtonActive]}
          onPress={handleExitPictureInPicture}
          disabled={!isPictureInPicture}
        >
          <Text style={styles.pipButtonText}>关闭画中画</Text>
        </TouchableOpacity>
      </View>

      <Video
        ref={videoRef}
        source={{ uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4' }}
        style={styles.video}
        resizeMode="contain"
        enterPictureInPictureOnLeave={enterPictureInPictureOnLeave}
        onPictureInPictureStatusChanged={(e: any) => {
          console.log('画中画状态变化:', e);
          if (isPictureInPicture !== e.isActive) {
            setIsPictureInPicture(e.isActive || false);
          }
        }}
      />
    </View>
  );
};

3. 全屏 + 画中画组合

typescript 复制代码
// 全屏 + 画中画组合模式
const CombinedModePlayer = () => {
  const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
  const [isPictureInPicture, setIsPictureInPicture] = useState<boolean>(false);
  const videoRef = useRef<VideoRef>(null);

  const toggleFullscreen = () => {
    if (isPictureInPicture) {
      videoRef.current?.exitPictureInPicture();
      setIsPictureInPicture(false);
    }
    if (isFullscreen) {
      videoRef.current?.dismissFullscreenPlayer();
      setIsFullscreen(false);
    } else {
      videoRef.current?.presentFullscreenPlayer();
      setIsFullscreen(true);
    }
  };

  const togglePictureInPicture = () => {
    if (isFullscreen) {
      videoRef.current?.dismissFullscreenPlayer();
      setIsFullscreen(false);
    }
    if (isPictureInPicture) {
      videoRef.current?.exitPictureInPicture();
      setIsPictureInPicture(false);
    } else {
      videoRef.current?.enterPictureInPicture();
      setIsPictureInPicture(true);
    }
  };

  return (
    <SafeAreaView style={[styles.container, isFullscreen && styles.fullscreenContainer]}>
      <StatusBar hidden={isFullscreen} />
      <Video
        ref={videoRef}
        source={{ uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4' }}
        style={[styles.video, isFullscreen && styles.fullscreenVideo]}
        resizeMode="contain"
        enterPictureInPictureOnLeave={true}
        onPictureInPictureStatusChanged={(e: any) => {
          setIsPictureInPicture(e.isActive || false);
        }}
        onFullscreenPlayerDidPresent={() => {
          setIsFullscreen(true);
        }}
        onFullscreenPlayerWillDismiss={() => {
          setIsFullscreen(false);
        }}
      />
      <View style={styles.controls}>
        <TouchableOpacity
          style={styles.controlButton}
          onPress={toggleFullscreen}
        >
          <Text style={styles.controlButtonText}>
            {isFullscreen ? '退出全屏' : '全屏'}
          </Text>
        </TouchableOpacity>
        <TouchableOpacity
          style={styles.controlButton}
          onPress={togglePictureInPicture}
        >
          <Text style={styles.controlButtonText}>
            {isPictureInPicture ? '关闭画中画' : '画中画'}
          </Text>
        </TouchableOpacity>
      </View>
    </SafeAreaView>
  );
};

三、实战完整版:全屏播放与画中画播放器

typescript 复制代码
import React, { useState, useRef, useEffect } from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  SafeAreaView,
  StatusBar,
  Dimensions,
  Switch,
  ScrollView,
  ActivityIndicator,
  AppState,
} from 'react-native';
import Video, { VideoRef, ResizeMode } from 'react-native-video';

const FullscreenPiPScreen = () => {
  const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
  const [isPictureInPicture, setIsPictureInPicture] = useState<boolean>(false);
  const [enterPictureInPictureOnLeave, setEnterPictureInPictureOnLeave] = useState<boolean>(true);
  const [isPlaying, setIsPlaying] = useState<boolean>(false);
  const [currentTime, setCurrentTime] = useState<number>(0);
  const [duration, setDuration] = useState<number>(0);
  const [volume, setVolume] = useState<number>(1.0);
  const [resizeMode, setResizeMode] = useState<ResizeMode>(ResizeMode.CONTAIN);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [isControlsVisible, setIsControlsVisible] = useState<boolean>(true);

  const videoRef = useRef<VideoRef>(null);
  const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  const videoUri = 'https://media.w3.org/2010/05/sintel/trailer.mp4';

  const toggleFullscreen = () => {
    if (isPictureInPicture) {
      videoRef.current?.exitPictureInPicture();
      setIsPictureInPicture(false);
    }
    if (isFullscreen) {
      videoRef.current?.dismissFullscreenPlayer();
      setIsFullscreen(false);
    } else {
      videoRef.current?.presentFullscreenPlayer();
      setIsFullscreen(true);
    }
    showControls();
  };

  const togglePictureInPicture = () => {
    if (isPictureInPicture) {
      videoRef.current?.exitPictureInPicture();
      setIsPictureInPicture(false);
    } else {
      videoRef.current?.enterPictureInPicture();
      setIsPictureInPicture(true);
    }
    showControls();
  };

  // 监听应用状态变化,处理画中画
  useEffect(() => {
    const subscription = AppState.addEventListener('change', (nextAppState) => {
      if (nextAppState === 'background' && enterPictureInPictureOnLeave && !isPictureInPicture) {
        videoRef.current?.enterPictureInPicture();
        setIsPictureInPicture(true);
      }
    });
    return () => subscription?.remove();
  }, [enterPictureInPictureOnLeave, isPictureInPicture]);

  const togglePlayPause = () => {
    setIsPlaying(prev => !prev);
    showControls();
  };

  const handleSeek = (value: number) => {
    if (videoRef.current) {
      videoRef.current.seek(value);
    }
    showControls();
  };

  const handleProgress = (data: any) => {
    setCurrentTime(data.currentTime);
  };

  const handleLoad = (data: any) => {
    setDuration(data.duration);
    setIsLoading(false);
  };

  const handleLoadStart = () => {
    setIsLoading(true);
  };

  const handleEnd = () => {
    setIsPlaying(false);
    setCurrentTime(0);
  };

  const showControls = () => {
    setIsControlsVisible(true);
    if (controlsTimeoutRef.current) {
      clearTimeout(controlsTimeoutRef.current);
    }
    controlsTimeoutRef.current = setTimeout(() => {
      setIsControlsVisible(false);
    }, 4000);
  };

  const formatTime = (seconds: number): string => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  };

  const getResizeModeLabel = (mode: ResizeMode): string => {
    switch (mode) {
      case ResizeMode.CONTAIN:
        return '适应';
      case ResizeMode.COVER:
        return '填充';
      case ResizeMode.STRETCH:
        return '拉伸';
      case ResizeMode.NONE:
        return '原始';
      default:
        return '适应';
    }
  };

  useEffect(() => {
    return () => {
      if (controlsTimeoutRef.current) {
        clearTimeout(controlsTimeoutRef.current);
      }
    };
  }, []);

  // 横屏全屏渲染
  if (isFullscreen) {
    return (
      <SafeAreaView style={styles.fullscreenLandscapeContainer}>
        <StatusBar hidden={true} />
        <View style={styles.fullscreenLandscapeVideoWrapper}>
          <TouchableOpacity
            activeOpacity={1}
            style={styles.videoContainer}
            onPress={showControls}
          >
            <Video
              ref={videoRef}
              source={{ uri: videoUri, isNetwork: true }}
              style={styles.fullscreenLandscapeVideo}
              resizeMode={resizeMode}
              paused={!isPlaying}
              volume={volume}
              enterPictureInPictureOnLeave={enterPictureInPictureOnLeave}
              onProgress={handleProgress}
              onLoad={handleLoad}
              onLoadStart={handleLoadStart}
              onEnd={handleEnd}
              onPictureInPictureStatusChanged={(e: any) => {
                console.log('画中画状态变化:', e);
                if (isPictureInPicture !== e.isActive) {
                  setIsPictureInPicture(e.isActive || false);
                }
              }}
              onFullscreenPlayerDidPresent={() => {
                setIsFullscreen(true);
              }}
              onFullscreenPlayerWillDismiss={() => {
                setIsFullscreen(false);
              }}
              repeat={false}
              controls={false}
            />

            {isLoading && (
              <View style={styles.loadingContainer}>
                <ActivityIndicator size="large" color="#007DFF" />
                <Text style={styles.loadingText}>加载中...</Text>
              </View>
            )}

            {isControlsVisible && (
              <View style={styles.controls}>
                <TouchableOpacity
                  style={styles.controlButton}
                  onPress={togglePlayPause}
                >
                  <Text style={styles.controlButtonText}>
                    {isPlaying ? '⏸' : '▶'}
                  </Text>
                </TouchableOpacity>

                <TouchableOpacity
                  style={styles.controlButton}
                  onPress={() => handleSeek(currentTime - 10)}
                >
                  <Text style={styles.controlButtonText}>-10s</Text>
                </TouchableOpacity>

                <TouchableOpacity
                  style={styles.controlButton}
                  onPress={() => handleSeek(currentTime + 10)}
                >
                  <Text style={styles.controlButtonText}>+10s</Text>
                </TouchableOpacity>

                <View style={styles.progressContainer}>
                  <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
                  <View style={styles.progressBar}>
                    <View
                      style={[
                        styles.progressFill,
                        { width: `${(currentTime / duration) * 100}%` }
                      ]}
                    />
                  </View>
                  <Text style={styles.timeText}>{formatTime(duration)}</Text>
                </View>

                <TouchableOpacity
                  style={styles.controlButton}
                  onPress={toggleFullscreen}
                >
                  <Text style={styles.controlButtonText}>
                    {'⬇'}
                  </Text>
                </TouchableOpacity>

                <TouchableOpacity
                  style={styles.controlButton}
                  onPress={togglePictureInPicture}
                >
                  <Text style={styles.controlButtonText}>
                    {isPictureInPicture ? '🔲' : '📺'}
                  </Text>
                </TouchableOpacity>
              </View>
            )}
          </TouchableOpacity>
        </View>
      </SafeAreaView>
    );
  }

  return (
    <SafeAreaView style={[styles.container, isFullscreen && styles.fullscreenContainer]}>
      <StatusBar hidden={isFullscreen} />

      <View style={[styles.videoWrapper, isFullscreen && styles.fullscreenVideoWrapper]}>
        <TouchableOpacity
          activeOpacity={1}
          style={styles.videoContainer}
          onPress={showControls}
        >
          <Video
            ref={videoRef}
            source={{ uri: videoUri, isNetwork: true }}
            style={styles.video}
            resizeMode={resizeMode}
            paused={!isPlaying}
            volume={volume}
            enterPictureInPictureOnLeave={enterPictureInPictureOnLeave}
            onProgress={handleProgress}
            onLoad={handleLoad}
            onLoadStart={handleLoadStart}
            onEnd={handleEnd}
            onPictureInPictureStatusChanged={(e: any) => {
              console.log('画中画状态变化:', e);
              if (isPictureInPicture !== e.isActive) {
                setIsPictureInPicture(e.isActive || false);
              }
            }}
            repeat={false}
            controls={false}
          />

          {isLoading && (
            <View style={styles.loadingContainer}>
              <ActivityIndicator size="large" color="#007DFF" />
              <Text style={styles.loadingText}>加载中...</Text>
            </View>
          )}

          {isControlsVisible && (
            <View style={styles.controls}>
              <TouchableOpacity
                style={styles.controlButton}
                onPress={togglePlayPause}
              >
                <Text style={styles.controlButtonText}>
                  {isPlaying ? '⏸' : '▶'}
                </Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={styles.controlButton}
                onPress={() => {
                  if (videoRef.current) {
                    videoRef.current.seek(Math.max(0, currentTime - 10));
                  }
                  showControls();
                }}
              >
                <Text style={styles.controlButtonText}>-10s</Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={styles.controlButton}
                onPress={() => {
                  if (videoRef.current) {
                    videoRef.current.seek(Math.min(duration, currentTime + 10));
                  }
                  showControls();
                }}
              >
                <Text style={styles.controlButtonText}>+10s</Text>
              </TouchableOpacity>

              <View style={styles.progressContainer}>
                <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
                <View style={styles.progressBar}>
                  <View
                    style={[
                      styles.progressFill,
                      { width: `${(currentTime / duration) * 100}%` }
                    ]}
                  />
                </View>
                <Text style={styles.timeText}>{formatTime(duration)}</Text>
              </View>

              <TouchableOpacity
                style={styles.controlButton}
                onPress={toggleFullscreen}
              >
                <Text style={styles.controlButtonText}>
                  {isFullscreen ? '⬇' : '⬆'}
                </Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={styles.controlButton}
                onPress={togglePictureInPicture}
              >
                <Text style={styles.controlButtonText}>
                  {isPictureInPicture ? '📺' : '📺'}
                </Text>
              </TouchableOpacity>
            </View>
          )}
        </TouchableOpacity>
      </View>

      {!isFullscreen && (
        <ScrollView style={styles.settingsContainer}>
          <View style={styles.card}>
            <Text style={styles.cardTitle}>播放模式</Text>

            <View style={styles.settingRow}>
              <Text style={styles.settingLabel}>全屏播放</Text>
              <TouchableOpacity
                style={[styles.modeButton, isFullscreen && styles.modeButtonActive]}
                onPress={toggleFullscreen}
              >
                <Text style={[styles.modeButtonText, isFullscreen && styles.modeButtonTextActive]}>
                  {isFullscreen ? '已开启' : '点击开启'}
                </Text>
              </TouchableOpacity>
            </View>

            <View style={styles.settingRow}>
              <Text style={styles.settingLabel}>画中画</Text>
              <TouchableOpacity
                style={[styles.modeButton, isPictureInPicture && styles.modeButtonActive]}
                onPress={togglePictureInPicture}
              >
                <Text style={[styles.modeButtonText, isPictureInPicture && styles.modeButtonTextActive]}>
                  {isPictureInPicture ? '已开启' : '点击开启'}
                </Text>
              </TouchableOpacity>
            </View>

            <View style={styles.settingRow}>
              <Text style={styles.settingLabel}>
                返回桌面自动画中画
              </Text>
              <Switch
                trackColor={{ false: '#E5E6EB', true: '#007DFF' }}
                thumbColor={enterPictureInPictureOnLeave ? '#007DFF' : '#fff'}
                onValueChange={setEnterPictureInPictureOnLeave}
                value={enterPictureInPictureOnLeave}
              />
            </View>
          </View>

          <View style={styles.card}>
            <Text style={styles.cardTitle}>视频设置</Text>

            <View style={styles.settingRow}>
              <Text style={styles.settingLabel}>缩放模式</Text>
              <View style={styles.resizeModeButtons}>
                {[
                  ResizeMode.CONTAIN,
                  ResizeMode.COVER,
                  ResizeMode.STRETCH,
                  ResizeMode.NONE,
                ].map((mode) => (
                  <TouchableOpacity
                    key={mode}
                    style={[
                      styles.resizeModeButton,
                      resizeMode === mode && styles.resizeModeButtonActive,
                    ]}
                    onPress={() => setResizeMode(mode)}
                  >
                    <Text style={[
                      styles.resizeModeButtonText,
                      resizeMode === mode && styles.resizeModeButtonTextActive,
                    ]}>
                      {getResizeModeLabel(mode)}
                    </Text>
                  </TouchableOpacity>
                ))}
              </View>
            </View>

            <View style={styles.settingRow}>
              <Text style={styles.settingLabel}>音量: {Math.round(volume * 100)}%</Text>
              <View style={styles.volumeContainer}>
                <TouchableOpacity
                  style={styles.volumeButton}
                  onPress={() => setVolume(Math.max(0, volume - 0.1))}
                >
                  <Text style={styles.volumeButtonText}>-</Text>
                </TouchableOpacity>
                <View style={styles.volumeBar}>
                  <View
                    style={[
                      styles.volumeFill,
                      { width: `${volume * 100}%` }
                    ]}
                  />
                </View>
                <TouchableOpacity
                  style={styles.volumeButton}
                  onPress={() => setVolume(Math.min(1, volume + 0.1))}
                >
                  <Text style={styles.volumeButtonText}>+</Text>
                </TouchableOpacity>
              </View>
            </View>
          </View>

          <View style={styles.card}>
            <Text style={styles.cardTitle}>使用说明</Text>
            <Text style={styles.instructionText}>
              1. 点击全屏按钮进入全屏模式
            </Text>
            <Text style={styles.instructionText}>
              2. 点击画中画按钮开启画中画
            </Text>
            <Text style={styles.instructionText}>
              3. 画中画模式下可以返回桌面继续观看
            </Text>
            <Text style={styles.instructionText}>
              4. 开启自动画中画返回桌面时自动启动
            </Text>
            <Text style={[styles.instructionText, { color: '#F44336', fontWeight: '600' }]}>
              ⚠️ 注意: 画中画需要鸿蒙系统支持
            </Text>
            <Text style={[styles.instructionText, { color: '#9C27B0', fontWeight: '600' }]}>
              💡 提示: 全屏模式隐藏状态栏
            </Text>
            <Text style={[styles.instructionText, { color: '#4CAF50', fontWeight: '600' }]}>
              💡 提示: 支持 4 种缩放模式
            </Text>
          </View>

          <View style={styles.card}>
            <Text style={styles.cardTitle}>常用属性</Text>
            <Text style={styles.instructionText}>
              • pictureInPicture: 是否开启画中画
            </Text>
            <Text style={styles.instructionText}>
              • enterPictureInPictureOnLeave: 返回桌面时自动画中画
            </Text>
            <Text style={styles.instructionText}>
              • onPictureInPictureStatusChanged: 画中画状态变化回调
            </Text>
            <Text style={styles.instructionText}>
              • resizeMode: 视频缩放模式
            </Text>
            <Text style={styles.instructionText}>
              • volume: 音量大小(0-1)
            </Text>
            <Text style={styles.instructionText}>
              • onProgress: 播放进度回调
            </Text>
            <Text style={styles.instructionText}>
              • onLoad: 视频加载完成回调
            </Text>
            <Text style={styles.instructionText}>
              • onEnd: 播放结束回调
            </Text>
          </View>
        </ScrollView>
      )}
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F2F3F5',
  },
  fullscreenContainer: {
    backgroundColor: '#000',
  },
  videoWrapper: {
    width: '90%',
    aspectRatio: 16 / 9,
    backgroundColor: '#000',
    borderRadius: 12,
    overflow: 'hidden',
    elevation: 4,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 4,
    alignSelf: 'center',
    marginTop: 20,
  },
  fullscreenVideoWrapper: {
    width: '100%',
    height: '100%',
    borderRadius: 0,
    elevation: 0,
    shadowOpacity: 0,
    marginTop: 0,
    aspectRatio: 16 / 9,
  },
  videoContainer: {
    width: '100%',
    height: '100%',
    justifyContent: 'center',
    alignItems: 'center',
  },
  video: {
    width: '100%',
    height: '100%',
  },
  loadingContainer: {
    position: 'absolute',
    justifyContent: 'center',
    alignItems: 'center',
  },
  loadingText: {
    marginTop: 8,
    fontSize: 14,
    color: '#fff',
  },
  controls: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    backgroundColor: 'rgba(0, 0, 0, 0.7)',
    padding: 15,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
  },
  controlButton: {
    width: 40,
    height: 40,
    borderRadius: 20,
    backgroundColor: 'rgba(255, 255, 255, 0.2)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  controlButtonText: {
    fontSize: 18,
    color: '#fff',
    fontWeight: '600',
  },
  progressContainer: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 10,
  },
  progressBar: {
    flex: 1,
    height: 6,
    backgroundColor: 'rgba(255, 255, 255, 0.3)',
    borderRadius: 3,
    overflow: 'hidden',
  },
  progressFill: {
    height: '100%',
    backgroundColor: '#007DFF',
  },
  timeText: {
    fontSize: 12,
    color: '#fff',
    minWidth: 40,
    textAlign: 'center',
  },
  settingsContainer: {
    flex: 1,
    padding: 20,
  },
  card: {
    backgroundColor: '#fff',
    borderRadius: 12,
    padding: 16,
    marginBottom: 16,
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
  },
  cardTitle: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 16,
    color: '#333',
  },
  settingRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 16,
  },
  settingLabel: {
    fontSize: 14,
    color: '#333',
    fontWeight: '500',
  },
  modeButton: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    backgroundColor: '#E5E6EB',
    borderRadius: 8,
  },
  modeButtonActive: {
    backgroundColor: '#007DFF',
  },
  modeButtonText: {
    fontSize: 14,
    color: '#333',
    fontWeight: '500',
  },
  modeButtonTextActive: {
    color: '#fff',
  },
  resizeModeButtons: {
    flexDirection: 'row',
    gap: 8,
  },
  resizeModeButton: {
    paddingHorizontal: 12,
    paddingVertical: 6,
    backgroundColor: '#E5E6EB',
    borderRadius: 6,
  },
  resizeModeButtonActive: {
    backgroundColor: '#007DFF',
  },
  resizeModeButtonText: {
    fontSize: 13,
    color: '#333',
    fontWeight: '500',
  },
  resizeModeButtonTextActive: {
    color: '#fff',
  },
  volumeContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    flex: 1,
  },
  volumeButton: {
    width: 32,
    height: 32,
    borderRadius: 16,
    backgroundColor: '#E5E6EB',
    justifyContent: 'center',
    alignItems: 'center',
  },
  volumeButtonText: {
    fontSize: 16,
    color: '#333',
    fontWeight: '600',
  },
  volumeBar: {
    flex: 1,
    height: 6,
    backgroundColor: '#E5E6EB',
    borderRadius: 3,
    overflow: 'hidden',
  },
  volumeFill: {
    height: '100%',
    backgroundColor: '#007DFF',
  },
  instructionText: {
    fontSize: 14,
    lineHeight: 22,
    marginBottom: 8,
    color: '#333',
  },
  fullscreenLandscapeContainer: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 0,
    bottom: 0,
    backgroundColor: '#000',
    zIndex: 9999,
  },
  fullscreenLandscapeVideoWrapper: {
    width: '100%',
    height: '100%',
  },
  fullscreenLandscapeVideo: {
    width: '100%',
    height: '100%',
  },
});

export default FullscreenPiPScreen;

四、鸿蒙端画中画与全屏实现说明

重要提示

在鸿蒙端使用画中画和全屏功能时,需要注意以下关键点:

  1. 使用 ref 方法控制画中画 :通过 videoRef.current?.enterPictureInPicture()videoRef.current?.exitPictureInPicture() 方法来控制画中画的开启和关闭
  2. 使用官方全屏方法 :通过 videoRef.current?.presentFullscreenPlayer()videoRef.current?.dismissFullscreenPlayer() 方法来控制全屏的进入和退出
  3. pictureInPicture 属性不支持 :根据官方文档,pictureInPicture 属性在鸿蒙端不支持,请勿使用
  4. 使用 enterPictureInPictureOnLeave :通过设置 enterPictureInPictureOnLeave={true} 来实现应用返回桌面时自动启动画中画
  5. 监听全屏事件 :使用 onFullscreenPlayerDidPresentonFullscreenPlayerWillDismiss 来监听全屏状态变化
  6. 监听画中画状态 :使用 onPictureInPictureStatusChanged 事件来监听画中画状态的变化

正确的实现方式

typescript 复制代码
const videoRef = useRef<VideoRef>(null);
const [isPictureInPicture, setIsPictureInPicture] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);

// 全屏控制
const toggleFullscreen = () => {
  if (isFullscreen) {
    videoRef.current?.dismissFullscreenPlayer();
    setIsFullscreen(false);
  } else {
    videoRef.current?.presentFullscreenPlayer();
    setIsFullscreen(true);
  }
};

// 开启画中画
const handleEnterPictureInPicture = () => {
  videoRef.current?.enterPictureInPicture();
  setIsPictureInPicture(true);
};

// 关闭画中画
const handleExitPictureInPicture = () => {
  videoRef.current?.exitPictureInPicture();
  setIsPictureInPicture(false);
};

// Video 组件配置
<Video
  ref={videoRef}
  source={{ uri: videoUri }}
  enterPictureInPictureOnLeave={enterPictureInPictureOnLeave} // 支持的属性
  onPictureInPictureStatusChanged={(e: any) => {
    setIsPictureInPicture(e.isActive || false);
  }}
  onFullscreenPlayerDidPresent={() => {
    setIsFullscreen(true);
  }}
  onFullscreenPlayerWillDismiss={() => {
    setIsFullscreen(false);
  }}
  // ... 其他配置
/>

功能说明

全屏功能:

  • presentFullscreenPlayer():通过 ref 方法进入全屏模式(鸿蒙端支持)
  • dismissFullscreenPlayer():通过 ref 方法退出全屏模式(鸿蒙端支持)
  • onFullscreenPlayerDidPresent:全屏播放器已进入全屏时触发(鸿蒙端支持)
  • onFullscreenPlayerWillDismiss:全屏播放器即将退出全屏时触发(鸿蒙端支持)

画中画功能:

  • enterPictureInPicture():通过 ref 方法进入画中画模式(鸿蒙端支持)
  • exitPictureInPicture():通过 ref 方法退出画中画模式(鸿蒙端支持)
  • enterPictureInPictureOnLeave:当用户离开应用时是否自动进入画中画模式(鸿蒙端支持)
  • onPictureInPictureStatusChanged:画中画激活或失效时触发的回调(鸿蒙端支持)
  • pictureInPicture:决定媒体是否以画中画模式播放(鸿蒙端不支持,请勿使用)

五、OpenHarmony6.0 专属避坑指南

以下是鸿蒙 RN 开发中实现「全屏播放与画中画」的所有真实高频踩坑点,按出现频率排序,问题现象贴合开发实际,解决方案均为「一行代码/简单配置」,所有方案均为鸿蒙端专属最优解:

问题现象 问题原因 鸿蒙端最优解决方案
全屏切换时视频变形 ResizeMode 未正确设置或容器尺寸未调整 ✅ 使用 ResizeMode.CONTAIN 保持比例,全屏时使用横屏布局
画中画无法启动 未使用正确的 ref 方法或系统未授权 ✅ 使用 videoRef.current?.enterPictureInPicture() 方法,确保应用有画中画权限
全屏状态栏未隐藏 StatusBar 的 hidden 属性未正确设置 ✅ 使用 <StatusBar hidden={isFullscreen} /> 控制状态栏
画中画返回后状态错误 画中画状态变化回调未正确处理 ✅ 在 onPictureInPictureStatusChanged 中同步状态,使用 ref 方法控制
画中画视频无声音 音量未正确设置或被静音 ✅ 确保 volume 属性正确设置为 1.0
全屏控制栏无法隐藏 控制栏自动隐藏定时器未设置 ✅ 使用 setTimeout 4秒后隐藏控制栏
画中画小窗口位置错误 未正确设置画中画窗口参数 ✅ 使用系统默认画中画位置,无需手动设置
全屏播放时返回黑屏 全屏状态未正确清理 ✅ 退出全屏时重置容器尺寸和状态
画中画切换到全屏失败 两种模式切换时状态冲突 ✅ 切换前先使用 videoRef.current?.exitPictureInPicture() 关闭画中画

⚠️ 特别注意:鸿蒙端使用全屏和画中画的要求:

  • 系统权限 - ✅ 画中画需要在系统设置中开启
  • ref 方法控制 - ✅ 画中画必须使用 videoRef.current?.enterPictureInPicture()videoRef.current?.exitPictureInPicture() 方法控制
  • 状态栏处理 - ✅ 全屏时隐藏状态栏,非全屏时显示
  • 状态同步 - ✅ 画中画状态变化时需要同步到应用状态
  • 模式互斥 - ✅ 全屏和画中画不能同时开启
  • 横屏全屏 - ✅ 全屏模式默认为横屏布局,无需监听屏幕旋转

六、扩展用法:全屏与画中画高频进阶优化(纯原生 无依赖 鸿蒙适配)

基于本次的核心代码,结合 RN 的内置能力,可轻松实现鸿蒙端开发中所有高频的全屏和画中画进阶需求

✔️ 扩展1:双击全屏

双击视频快速切换全屏:

typescript 复制代码
const [lastTap, setLastTap] = useState<number>(0);

const handleDoubleTap = () => {
  const now = Date.now();
  if (now - lastTap < 300) {
    toggleFullscreen();
  }
  setLastTap(now);
};

<TouchableOpacity onPress={handleDoubleTap}>
  <Video />
</TouchableOpacity>

✔️ 扩展2:画中画手势控制

在画中画模式下支持拖动和缩放:

typescript 复制代码
const onPictureInPictureStatusChanged = (e: any) => {
  if (e.isActive) {
    console.log('画中画已开启,支持手势控制');
  }
};

✔️ 扩展3:全屏横竖屏锁定

锁定全屏时的屏幕方向:

typescript 复制代码
const [lockOrientation, setLockOrientation] = useState<boolean>(false);

useEffect(() => {
  if (isFullscreen && lockOrientation) {
    // 锁定横屏
    console.log('锁定横屏');
  }
}, [isFullscreen, lockOrientation]);

✔️ 扩展4:画中画自定义尺寸

自定义画中画窗口的大小:

typescript 复制代码
const customPictureInPictureSize = {
  width: 200,
  height: 150,
};

// 注意:鸿蒙端画中画尺寸由系统控制,开发者无法自定义
相关推荐
果粒蹬i2 小时前
【HarmonyOS】鸿蒙React Native 实战:打造流畅的底部导航
react native·华为·harmonyos
2501_921930832 小时前
基础入门 React Native 鸿蒙跨平台开发:react-native-switch 开关适配
react native·react.js·harmonyos
王码码20352 小时前
Flutter for OpenHarmony 实战之基础组件:第十八篇 布局终极者 CustomScrollView 与 Slivers
flutter·harmonyos
一起养小猫2 小时前
Flutter for OpenHarmony 实战:打地鼠游戏完整开发指南
flutter·游戏·harmonyos
摘星编程2 小时前
在OpenHarmony上用React Native:ImageGIF动图播放
javascript·react native·react.js
一起养小猫2 小时前
Flutter for OpenHarmony 实战:打地鼠游戏难度设计与平衡性
flutter·游戏·harmonyos
果粒蹬i2 小时前
OpenHarmony 跨平台开发实战:第一阶段的踩坑记录与深度复盘
harmonyos
摘星编程3 小时前
React Native + OpenHarmony:Text文本高亮显示
javascript·react native·react.js
Betelgeuse763 小时前
【Flutter For OpenHarmony】 阶段复盘:从单页Demo到模块化App
flutter·ui·华为·交互·harmonyos