React Native for OpenHarmony 实战:Steam 资讯 App 游戏视频页面

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam
请添加图片描述
游戏视频是展示游戏动态效果的重要方式。视频页面需要展示游戏的所有视频,并支持视频播放、列表管理等功能。这个页面涉及视频播放器集成、列表渲染和交互设计。

视频数据的来源

游戏的视频数据同样来自 appdetails API 的返回结果。这个 API 返回的数据中包含 movies 数组:

export const getAppDetails = async (appId: number) => {
  const res = await fetch(`${STORE_API}/appdetails?appids=${appId}`);
  return res.json();
};

这里做了什么: 调用 appdetails 接口获取游戏详情。这个接口返回的数据包含游戏的所有信息,包括视频、截图等。

为什么复用 appdetails: 虽然可以单独调用视频 API,但 appdetails 已经包含了所有需要的信息。这样可以减少 API 调用次数,提高效率。

数据结构: appdetails 返回的 movies 数组大概是这样的:

{
  "movies": [
    {
      "id": 256658,
      "name": "Trailer",
      "thumbnail": "https://cdn.cloudflare.steamstatic.com/steam/apps/256658/movie.293x165.jpg",
      "webm": {
        "480": "https://cdn.cloudflare.steamstatic.com/steam/apps/256658/movie480.webm",
        "max": "https://cdn.cloudflare.steamstatic.com/steam/apps/256658/movie_max.webm"
      },
      "mp4": {
        "480": "https://cdn.cloudflare.steamstatic.com/steam/apps/256658/movie480.mp4",
        "max": "https://cdn.cloudflare.steamstatic.com/steam/apps/256658/movie_max.mp4"
      }
    }
  ]
}

关键字段说明:

  • id - 视频的唯一标识
  • name - 视频名称(比如"Trailer"、“Gameplay”)
  • thumbnail - 视频缩略图 URL
  • webm - WebM 格式的视频 URL(不同分辨率)
  • mp4 - MP4 格式的视频 URL(不同分辨率)

页面结构设计

视频页面的布局需要展示视频列表:

顶部 是 Header,显示游戏名称和返回按钮。

中间 是视频列表,每个视频显示缩略图、名称和时长。

点击视频 可以打开视频播放器进行播放。

底部 是 TabBar,保持应用的导航一致性。

核心代码实现

组件初始化和数据加载

export const GameVideosScreen = () => {
  const {selectedAppId} = useApp();
  const [videos, setVideos] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [selectedVideo, setSelectedVideo] = useState<any>(null);

这里做了什么: 定义了视频列表、加载状态和当前选中的视频。selectedVideo 用来存储用户点击的视频,用于播放。

状态的作用: videos 存储所有视频数据,loading 控制加载动画,selectedVideo 控制视频播放器的显示。

为什么分离 selectedVideo: 这样可以独立控制视频播放器的显示和隐藏,不需要修改 videos 数组。

数据加载

  useEffect(() => {
    if (!selectedAppId) return;
    
    getAppDetails(selectedAppId).then(data => {
      const gameData = data?.[selectedAppId]?.data;
      setVideos(gameData?.movies || []);
      setLoading(false);
    }).catch(() => setLoading(false));
  }, [selectedAppId]);

这里做了什么: 当组件挂载或 selectedAppId 变化时,调用 getAppDetails() 获取游戏详情,然后提取 movies 数组。

数据提取的安全性: 使用可选链操作符 ?. 安全地访问嵌套属性。如果某个属性不存在,不会抛出错误。

默认值的处理: 如果 movies 不存在,使用空数组作为默认值。这样即使 API 返回的数据不完整,也不会导致应用崩溃。

错误处理: 即使请求失败,也会把 loading 设为 false,这样用户不会一直看到加载动画。

视频列表的渲染

<FlatList
  data={videos}
  keyExtractor={(item) => item.id.toString()}
  renderItem={({item}) => (
    <TouchableOpacity
      style={styles.videoItem}
      onPress={() => setSelectedVideo(item)}
    >
      <Image
        source={{uri: item.thumbnail}}
        style={styles.thumbnail}
        cache="force-cache"
      />
      <View style={styles.playIcon}>
        <Text style={styles.playIconText}>▶</Text>
      </View>
      <View style={styles.videoInfo}>
        <Text style={styles.videoName} numberOfLines={2}>{item.name}</Text>
      </View>
    </TouchableOpacity>
  )}
/>

这里做了什么:

  • 缩略图显示 - 使用 thumbnail 显示视频缩略图
  • 播放图标 - 在缩略图上显示播放按钮
  • 视频名称 - 显示视频的名称
  • 点击播放 - 点击时设置 selectedVideo,打开播放器

为什么显示播放图标: 播放图标可以清晰地表示这是一个可播放的视频。用户能一眼看出这是视频而不是图片。

缩略图的优势: 使用缩略图而不是完整视频可以显著减少加载时间。缩略图通常只有几十 KB。

图片缓存: cache="force-cache" 告诉 React Native 优先使用缓存的图片。这样用户滚动列表时,已经加载过的缩略图会从缓存读取。

视频播放器

{selectedVideo && (
  <Modal visible={true} transparent={false}>
    <View style={styles.playerContainer}>
      <TouchableOpacity
        style={styles.closeBtn}
        onPress={() => setSelectedVideo(null)}
      >
        <Text style={styles.closeBtnText}>✕</Text>
      </TouchableOpacity>
      <Video
        source={{uri: selectedVideo.mp4.max}}
        style={styles.video}
        controls={true}
        resizeMode="contain"
        onError={(error) => console.log('Video error:', error)}
      />
    </View>
  </Modal>
)}

这里做了什么:

  • Modal 显示 - 当 selectedVideo 不为 null 时显示 Modal
  • Video 组件 - 使用 react-native-video 播放视频
  • controls={true} - 显示播放控制条
  • resizeMode=“contain” - 保持视频比例

为什么用 Modal: Modal 可以全屏显示视频播放器,提供更好的观看体验。用户可以通过关闭按钮返回列表。

选择 mp4.max: 这是最高质量的 MP4 视频。如果需要支持不同网络条件,可以根据网络速度选择不同分辨率。

controls 属性: 这个属性显示播放、暂停、进度条等控制按钮。用户可以方便地控制视频播放。

错误处理: onError 回调可以捕获视频播放错误。在实际项目中,应该显示错误提示给用户。

加载和空状态

if (loading) {
  return (
    <View style={styles.container}>
      <Header title="游戏视频" showBack />
      <Loading />
      <TabBar />
    </View>
  );
}

if (videos.length === 0) {
  return (
    <View style={styles.container}>
      <Header title="游戏视频" showBack />
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyText}>暂无视频</Text>
      </View>
      <TabBar />
    </View>
  );
}

这里做了什么:

  • 加载状态 - 显示 Loading 组件
  • 空状态 - 如果没有视频,显示"暂无视频"提示

用户体验: 通过显示明确的状态提示,用户能理解当前的情况。不会因为看到空白页面而困惑。

提前返回模式: 这种模式让代码更清晰,避免嵌套多层 if-else。

样式设计

视频项的样式

videoItem: {
  margin: 12,
  borderRadius: 8,
  overflow: 'hidden',
  backgroundColor: '#1b2838',
},
thumbnail: {
  width: '100%',
  height: 180,
},
playIcon: {
  position: 'absolute',
  width: 60,
  height: 60,
  borderRadius: 30,
  backgroundColor: 'rgba(255, 255, 255, 0.3)',
  justifyContent: 'center',
  alignItems: 'center',
  top: '50%',
  left: '50%',
  marginTop: -30,
  marginLeft: -30,
},
playIconText: {
  fontSize: 24,
  color: '#fff',
  fontWeight: 'bold',
},
videoInfo: {
  padding: 12,
},
videoName: {
  fontSize: 14,
  fontWeight: '600',
  color: '#acdbf5',
},

这里的设计考量:

  • margin: 12 - 视频项之间有 12 的间距
  • borderRadius: 8 - 圆角使视频项看起来更现代
  • overflow: ‘hidden’ - 超出边界的内容被隐藏
  • position: ‘absolute’ - 播放图标绝对定位在缩略图中心

margin 的作用: 项之间的间距使列表看起来不拥挤。12 是一个常见的间距单位。

overflow: ‘hidden’ 的作用: 这个属性确保圆角效果正确显示。如果不设置,缩略图可能会超出圆角边界。

播放图标的定位: 使用 top: '50%'left: '50%' 将播放图标放在中心,然后用负 margin 调整。这样可以精确控制位置。

半透明背景: 播放图标的背景是半透明的白色,这样可以在不同颜色的缩略图上都清晰显示。

播放器的样式

playerContainer: {
  flex: 1,
  backgroundColor: '#000',
  justifyContent: 'center',
  alignItems: 'center',
},
video: {
  width: '100%',
  height: '100%',
},
closeBtn: {
  position: 'absolute',
  top: 20,
  right: 20,
  width: 40,
  height: 40,
  borderRadius: 20,
  backgroundColor: 'rgba(255, 255, 255, 0.3)',
  justifyContent: 'center',
  alignItems: 'center',
  zIndex: 10,
},
closeBtnText: {
  fontSize: 24,
  color: '#fff',
  fontWeight: 'bold',
},

这里的设计考量:

  • backgroundColor: ‘#000’ - 黑色背景,适合视频播放
  • width: ‘100%’, height: ‘100%’ - 视频占据整个屏幕
  • position: ‘absolute’ - 关闭按钮绝对定位
  • zIndex: 10 - 确保关闭按钮在最上层

黑色背景的选择: 黑色背景可以让视频更突出,不会有其他颜色的干扰。这是视频播放器的标准做法。

全屏显示: 视频占据整个屏幕,提供沉浸式的观看体验。

关闭按钮的设计: 圆形按钮(borderRadius: 20)看起来更现代。半透明的白色背景可以在黑色背景上清晰显示。

视频播放的优化

自适应分辨率

const selectVideoUrl = (video: any) => {
  // 优先选择 MP4 格式
  if (video.mp4) {
    // 如果网络较好,选择最高质量
    return video.mp4.max || video.mp4['480'];
  }
  // 降级到 WebM 格式
  if (video.webm) {
    return video.webm.max || video.webm['480'];
  }
  return null;
};

这里做了什么:

  • 格式优先级 - 优先选择 MP4,降级到 WebM
  • 分辨率选择 - 优先选择最高质量,降级到 480p

为什么要自适应: 不同设备和网络条件下,用户需要不同质量的视频。高质量视频在 WiFi 下很好,但在 4G 下可能会卡顿。

MP4 vs WebM: MP4 的兼容性更好,大多数设备都支持。WebM 是备选方案。

分辨率降级: 如果最高质量的视频加载失败,可以自动降级到 480p。这样可以保证视频能播放。

预加载缩略图

const [thumbnailLoading, setThumbnailLoading] = useState<Record<number, boolean>>({});

<Image
  source={{uri: item.thumbnail}}
  style={styles.thumbnail}
  onLoadStart={() => setThumbnailLoading({...thumbnailLoading, [item.id]: true})}
  onLoadEnd={() => setThumbnailLoading({...thumbnailLoading, [item.id]: false})}
/>

{thumbnailLoading[item.id] && (
  <View style={styles.loadingOverlay}>
    <ActivityIndicator size="large" color="#66c0f4" />
  </View>
)}

这里做了什么:

  • 加载状态追踪 - 记录每个缩略图的加载状态
  • 加载动画 - 缩略图加载时显示加载动画

为什么要追踪加载状态: 这样可以在缩略图加载时显示占位符或加载动画。用户能看到缩略图正在加载。

Record 类型的使用: Record<number, boolean> 表示一个对象,键是视频 ID,值是加载状态。这样可以独立追踪每个缩略图的加载状态。

用户体验: 加载动画告诉用户缩略图正在加载,而不是应用卡住了。

视频播放事件处理

const [videoState, setVideoState] = useState({
  duration: 0,
  currentTime: 0,
  isPlaying: false,
});

<Video
  source={{uri: selectedVideo.mp4.max}}
  style={styles.video}
  controls={true}
  resizeMode="contain"
  onLoad={(data) => setVideoState({...videoState, duration: data.duration})}
  onProgress={(data) => setVideoState({...videoState, currentTime: data.currentTime})}
  onPlay={() => setVideoState({...videoState, isPlaying: true})}
  onPause={() => setVideoState({...videoState, isPlaying: false})}
  onError={(error) => console.log('Video error:', error)}
/>

这里做了什么:

  • onLoad - 视频加载完成时调用,获取视频时长
  • onProgress - 视频播放进度变化时调用
  • onPlay/onPause - 视频播放/暂停时调用

为什么要追踪这些事件: 这样可以实现自定义的播放控制、进度显示等功能。

videoState 的作用: 这个状态存储视频的播放信息。可以用来显示当前播放时间、总时长等。

onProgress 的用途: 这个回调可以用来更新进度条、显示当前播放时间等。

完整页面示例

import React, {useEffect, useState} from 'react';
import {View, Text, FlatList, TouchableOpacity, Image, StyleSheet, Modal, ActivityIndicator} from 'react-native';
import Video from 'react-native-video';
import {Header} from '../components/Header';
import {TabBar} from '../components/TabBar';
import {Loading} from '../components/Loading';
import {useApp} from '../store/AppContext';
import {getAppDetails} from '../api/steam';

export const GameVideosScreen = () => {
  const {selectedAppId} = useApp();
  const [videos, setVideos] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [selectedVideo, setSelectedVideo] = useState<any>(null);
  const [thumbnailLoading, setThumbnailLoading] = useState<Record<number, boolean>>({});

  useEffect(() => {
    if (!selectedAppId) return;
    
    getAppDetails(selectedAppId).then(data => {
      const gameData = data?.[selectedAppId]?.data;
      setVideos(gameData?.movies || []);
      setLoading(false);
    }).catch(() => setLoading(false));
  }, [selectedAppId]);

  const selectVideoUrl = (video: any) => {
    if (video.mp4) {
      return video.mp4.max || video.mp4['480'];
    }
    if (video.webm) {
      return video.webm.max || video.webm['480'];
    }
    return null;
  };

  if (loading) {
    return (
      <View style={styles.container}>
        <Header title="游戏视频" showBack />
        <Loading />
        <TabBar />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Header title="游戏视频" showBack />
      
      {videos.length === 0 ? (
        <View style={styles.emptyContainer}>
          <Text style={styles.emptyText}>暂无视频</Text>
        </View>
      ) : (
        <FlatList
          data={videos}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({item}) => (
            <TouchableOpacity
              style={styles.videoItem}
              onPress={() => setSelectedVideo(item)}
            >
              {thumbnailLoading[item.id] && (
                <View style={styles.loadingOverlay}>
                  <ActivityIndicator size="large" color="#66c0f4" />
                </View>
              )}
              <Image
                source={{uri: item.thumbnail}}
                style={styles.thumbnail}
                cache="force-cache"
                onLoadStart={() => setThumbnailLoading({...thumbnailLoading, [item.id]: true})}
                onLoadEnd={() => setThumbnailLoading({...thumbnailLoading, [item.id]: false})}
              />
              <View style={styles.playIcon}>
                <Text style={styles.playIconText}>▶</Text>
              </View>
              <View style={styles.videoInfo}>
                <Text style={styles.videoName} numberOfLines={2}>{item.name}</Text>
              </View>
            </TouchableOpacity>
          )}
        />
      )}

      {selectedVideo && (
        <Modal visible={true} transparent={false}>
          <View style={styles.playerContainer}>
            <TouchableOpacity
              style={styles.closeBtn}
              onPress={() => setSelectedVideo(null)}
            >
              <Text style={styles.closeBtnText}>✕</Text>
            </TouchableOpacity>
            <Video
              source={{uri: selectVideoUrl(selectedVideo)}}
              style={styles.video}
              controls={true}
              resizeMode="contain"
              onError={(error) => console.log('Video error:', error)}
            />
          </View>
        </Modal>
      )}

      <TabBar />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#171a21'},
  emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center'},
  emptyText: {fontSize: 16, color: '#8f98a0'},
  videoItem: {margin: 12, borderRadius: 8, overflow: 'hidden', backgroundColor: '#1b2838'},
  thumbnail: {width: '100%', height: 180},
  loadingOverlay: {position: 'absolute', width: '100%', height: 180, justifyContent: 'center', alignItems: 'center', backgroundColor: '#1b2838', zIndex: 5},
  playIcon: {position: 'absolute', width: 60, height: 60, borderRadius: 30, backgroundColor: 'rgba(255, 255, 255, 0.3)', justifyContent: 'center', alignItems: 'center', top: '50%', left: '50%', marginTop: -30, marginLeft: -30},
  playIconText: {fontSize: 24, color: '#fff', fontWeight: 'bold'},
  videoInfo: {padding: 12},
  videoName: {fontSize: 14, fontWeight: '600', color: '#acdbf5'},
  playerContainer: {flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center'},
  video: {width: '100%', height: '100%'},
  closeBtn: {position: 'absolute', top: 20, right: 20, width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255, 255, 255, 0.3)', justifyContent: 'center', alignItems: 'center', zIndex: 10},
  closeBtnText: {fontSize: 24, color: '#fff', fontWeight: 'bold'},
});

这里做了什么: 完整的游戏视频页面实现,包括:

  • 视频列表的加载和渲染
  • 视频缩略图显示
  • 播放图标显示
  • 视频播放器集成
  • 加载状态显示
  • 空状态处理

小结

游戏视频页面虽然功能相对简单,但涉及了很多实用的开发技巧:

  • 视频列表 - 使用 FlatList 渲染视频列表
  • 缩略图显示 - 使用缓存提高性能
  • 播放器集成 - 使用 react-native-video 播放视频
  • 加载状态 - 显示加载动画提升用户体验
  • 自适应分辨率 - 根据条件选择合适的视频质量
  • 事件处理 - 追踪视频播放事件

下一篇我们来实现游戏评测页面,这个页面会展示用户对游戏的评价和评分,涉及评测数据的展示和统计。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐