用 Mux、Stream 和 Flutter 进行直播推流

现在直播推流非常流行。无论是时下流行的游戏网站(比如 Twitch),还是社交应用(比如 Instagram ),都有直播推流功能,这些应用通过直播推流和视频直播增强与用户的联系,增强了平台的交互性。

如果你想创建直播推流应用,需要考虑许多细节。例如,大多数流行的推流平台除了可以视频直播外,还可以实时聊天,但这会花费开发人员大量的时间和成本。

本文会简要介绍视频直播的技术,然后用 Flutter 搭建一个小型推流应用。

如果你想快速浏览完本篇文章,可以略过这部分:

直播推流非常复杂,需要确保多种不同的部件的正确执行,大家在开始编写代码之前,必须了解一些直播推流功能的深层概念。原始视频在到达你的计算机之前就已被转换、压缩和转码。

背景

直播推流的历史可以追溯到 20 世纪 90 年代末和 21 世纪初,Macromedia 开发了用于直播推流的协议和实时消息协议(RTMP),该公司于 2005 年底被 Adobe 收购。

RTMP 协议是专有协议,旨在早期通过 Internet 在 Flash 服务器和多个 Flash 客户端之间推流视频和音频数据。RTMP 的早期版本具有低延迟的特点,通常从服务器到客户端有 3 到 5 秒的延迟。

过了一段时间,Flash 的统治地位开始动摇,移动、智能电视等新设备开始流行。最终,Adobe 决定实施 RTMP 协议,从而赋予了 RTMP 新的活力。

RTMP 在网络浏览器中的普遍应用一度使其成为推流服务器的黄金标准,但随着 HTTPS Live Streaming(HLS)等新的前端协议的兴起,RTMP 在客户端的流行度开始下降。

如果你想了解更多关于 RTMP 的信息,可以点击下面的 URL 去 Adobe 网站查看完整的规范:

实时消息协议(RTMP)规范

RTMP 的简介:RTMP 基于互联网的传输控制协议(俗称 TCP),默认使用端口 1935 进行通信。后来,该协议更新,增加了传输层安全性协议(TLS)支持的 RTMPS 和由 Adobe 开发的专有加密形式 RTMPE。

如今,RTMP 不再像以前那样占主导地位,但仍广泛应用于推流服务器,在直播推流周期的开端从源头提取原始视频。随后,推流应用会对视频进行编码、压缩和重新打包以便更好地适应终端设备。

基本的推流流程:bug:

0_xD5dCaElGGPOLj7c

流程步骤

大家经常通过自己的设备观看喜欢的电视和体育节目,但其实原始视频必须经过好几个步骤,才能到达用户设备。

首先,用摄像机或数字录像机拍摄原始视频。原始输入可能会非常大,不适合通过网络传输。为了让输入内容不占用太多内存,同时易于访问,就给原始视频编码并压缩到一个解码器中(例如 H.264,VP8)。可根据用户的需求来选择,但 H.264 是视频编码的首选。

接下来,使用推流协议将编码的视频分发到媒体服务器上。最近最受欢迎的推流协议是 RTMP,大家也可以使用其他协议,如 MPEG-DASH 或安全可靠传输协议(SRT)。

然后,将这些协议创建的推流发送到媒体服务器,在媒体服务器上对其进行转码、调整大小、并拆分为不同的分辨率和格式,以交付给最终用户。大多数情况下,推流会被重新打包为各种形式的质量和比特率,以更好地为其他互联网连接的用户提供服务,这个过程被称为“ transmuxing”。

最后,使用诸如 MPEG-DASH 或 Apple 的 HLS 之类的方法将推流发送给终端用户,这是两种使用最广泛且兼容性最好的直播推流方法。另外,大家经常通过内容交付网络或 CDN 分发推流,从而减少延迟,降低推流服务器负载。

实践推流

构建一个端到端的推流平台绝非易事。首先创建和维护端到端的流程所涉及的技术极其复杂,除此之外,还要开发和扩展流程,要在不同区域内维护服务器,进而实现低延迟播放,这样就增加了时间和成本。

还好这是某些公司和服务的强项。Mux 等推流平台支持把视频直播集成到开发人员或企业的应用中,从而让应用快速上市,扫除了一些视频直播应用开发会遇到的技术/经济障碍。

本文创建的推流应用就打算用 Mux 和 Stream 把视频直播和聊天功能集成到应用中。

我们即将搭建的应用的一些目标:

  • 播放自定义 HLS 和 RTMP 推流

  • 显示之前点播的推流的存档

  • 视频下方的实时消息和聊天

项目设置:package:

我们将使用 MuxStream 处理视频推流和实时消息,可以在 Mux 和 Stream 上创建免费帐户来获取 API 密钥。下面示例用的是 Mux,在 Mux 的首页输入电子邮件后或收到一封邮件,按照邮件内的说明进行操作。

0_IrhH5KfG4giUl-A4

使用 Stream 步骤跟 Mux 类似:输入用户名、电子邮件和密码。

0_y39g3NEu7MfvT1UM

然后,在 Mux 上创建一个视频推流测试项目,确保一切正常。找到屏幕左侧菜单,点击 video 选项下的子类别“Live Streams”。

我们可以直接在仪表板查看正在进行的推流,也可以创建新的推流。因为我们要进行测试,所以选择右上角的“Create New Live Stream”。

0_18hIRyoIPcIl9OwL

我们会看到一个控制台,可以在控制台上创建新的直播推流。因为我们用的是免费帐户,所以会有一条提示信息,提示我们的推流限制在 5 分钟之内。但是,此限制只限制视频的长度,并不限制对其他功能的使用。推流结束后,我们可以对直播推流的各个方面进行自定义,例如推流和资产的隐私设置。我们还可以为推流设置许多选项和配置。如果想了解更多关于选项的信息,我强烈建议大家查看 Mux 文档的入门指南

0_Vbuj-zW8FI-blCHO

运行请求后,会看到类似于下图的响应。一定要关注密钥是 stream_keyplayback_ids.id ,这两项稍后分别用于发布和查看我们的推流。

0_vQApURnsyqyHLSlt

:memo:注意:切勿公布流密钥,此值应始终设为私密。

最后,可以通过单击侧边菜单中的直播推流选项或底部的“View Live Stream”选项来查看新创建的推流的详细信息。

0_ThfgNZyOymcYN95O

此页面包含当前推流的信息。在我们的示例中,由于我们没有正在直播,因此推流被显示为“idle”。我们还可以从直播推流概述中看到唯一的直播推流 ID,侧面的缩略图预览以及推流的回放 ID。

为快速测试我们的推流,我们可以使用类似 Larix 直播应用在移动设备上进行直播。

Google Play 上的 Larix:

Larix Broadcaster - Google Play上的Apps

Larix Broadcaster允许通过 WiFi实时从你的移动设备流式传输视频或音频…
play.google.com

苹果 App Store 中的 Larix:

‎Larix Broadcaster

‎Larix Broadcaster Larix Broadcaster允许通过 WiFi 实时从你的移动设备直播推流视频或音频…
apps.apple.com

在 Larix 中,我们可以用 URL 创建一个新的连接 rtmps://global- live.mux.com:443/app/<YOUR-STREAM-KEY> 。我还用我的 Mux 凭证进行了 RTMP 认证,但这个步骤可以省略。

连接保存后,我们可以点击 Larix 上的红色“record”按钮开始直播。

%E6%9C%AA%E5%91%BD%E5%90%8D_%E5%89%AF%E6%9C%AC

要验证推流是否正常运行,请尝试刷新直播推流预览页面或访问 URL https://stream.mux.com/YOUR-PLAYBACK-ID.m3u8

恭喜:tada:,你已成功迈出了构建视频直播应用的第一步。接下来是构建应用程序的框架!

创建应用布局:man_artist:

首先,新建一个 Flutter 项目并命名,然后将下列内容添加到你的 pubspec.yaml 中:

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.0
  yoyo_player: ^0.1.0
  stream_chat_flutter: 0.2.15
  bloc: 6.1.0
  equatable: 1.2.5
  flutter_bloc: 6.1.1
  google_fonts: 1.1.1
dependency_overrides:
  video_player: 0.11.1+2

0_LVT3PrCgE7s9enqc

为简单起见,我们的应用有三个屏幕。第一个屏幕是所有用户启动应用时看到的登录页面,用户可以在登录页面输入自定义 URL 和昵称,与朋友一起观看视频直播,也可以直接转到应用的主页上查看当前和过去的直播推流列表。

另外,我们将视频回放屏幕分为两部分,顶部是视频播放器,底部是实时聊天。

编写登录页面:flight_arrival:

0_BQHAMGm1jhRaVjor

登录页面布局由一些小部件组成,Column 用于设计两个 TextField,一个 Icon ,和一个用于执行导航的操作按钮。如果用户输入自定义 URL,我们会将按钮从矩形 ElevatedButton 更改为带有图标的圆形按钮。

该页面的代码将类似于以下内容:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Form(
          key: formKey,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              ShaderMask(
                shaderCallback: (rect) {
                  return LinearGradient(
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                    colors: [
                      Color(0xFFf5c3ff),
                      Color(0xFF0047ff),
                    ],
                  ).createShader(rect);
                },
                child: Icon(
                  Icons.camera,
                  color: Colors.white,
                  size: 100.0,
                ),
              ),
              const SizedBox(height: 64.0),
              Container(
                margin: const EdgeInsets.symmetric(
                  horizontal: 48.0,
                ),
                height: 50.0,
                decoration: BoxDecoration(
                  color: Colors.grey[200],
                  borderRadius: BorderRadius.circular(12.0),
                ),
                child: Padding(
                  padding: const EdgeInsets.only(left: 6.0),
                  child: Center(
                    child: TextFormField(
                      controller: urlEditingController,
                      decoration: InputDecoration.collapsed(
                        hintText: "Custom HLS/RTP URL",
                      ),
                    ),
                  ),
                ),
              ),
              Container(
                margin: const EdgeInsets.symmetric(
                  horizontal: 48.0,
                  vertical: 12.0,
                ),
                height: 50.0,
                decoration: BoxDecoration(
                  color: Colors.grey[200],
                  borderRadius: BorderRadius.circular(12.0),
                ),
                child: Padding(
                  padding: const EdgeInsets.only(left: 6.0),
                  child: Center(
                    child: TextFormField(
                      validator: (val) => (val != null && val.isNotEmpty)
                          ? null
                          : "Please enter a name",
                      controller: nameEditingController,
                      decoration: InputDecoration.collapsed(
                        hintText: "Nickname",
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(height: 18.0),
              ValueListenableBuilder<TextEditingValue>(
                valueListenable: urlEditingController,
                builder: (context, controller, _) {
                  if (controller.text.isEmpty) {
                    return Align(
                      child: ElevatedButton(
                        style: ButtonStyle(
                          shadowColor: MaterialStateColor.resolveWith(
                            (states) =>
                                const Color(0xff4d7bfe).withOpacity(0.2),
                          ),
                          backgroundColor: MaterialStateColor.resolveWith(
                            (states) => const Color(0xff4d7bfe),
                          ),
                        ),
                        onPressed: onContinueToHomePressed, // TODO: Implement
                        child: Text("Continue to home"),
                      ),
                    );
                  } else {
                    return Align(
                      child: ElevatedButton(
                        style: ButtonStyle(
                          shape: MaterialStateProperty.all(
                            CircleBorder(),
                          ),
                          shadowColor: MaterialStateColor.resolveWith(
                            (states) =>
                                const Color(0xff4d7bfe).withOpacity(0.2),
                          ),
                          backgroundColor: MaterialStateColor.resolveWith(
                            (states) => const Color(0xff4d7bfe),
                          ),
                        ),
                        onPressed: onCustomUrlGoPressed, // TODO: Implement
                        child: Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Icon(
                            Icons.arrow_forward,
                            color: Colors.white,
                          ),
                        ),
                      ),
                    );
                  }
                },
              ),
            ],
          ),
        ),
      ),
    );
  }

:bulb:你知道可以使用 ShaderMaskover 微件来应用渐变吗?请注意,在上面的代码中,我们使用 ShaderMask 在登录页面图标上应用了渐变。

接下来,我们创建主页的布局~

主页布局

0_3Hd841r0N-2p65tk

主页设置有点复杂。为了便于管理,我选择使用两个单独的微件。

第一个微件包含一个静态方法,用于简化页面控制器的导航和初始化。稍后,我们将集成 cubit,来处理状态更改。

class HomePage extends StatefulWidget {
  static Route<dynamic> route() {
    return MaterialPageRoute<dynamic>(
      builder: (BuildContext context) {
        return HomePage();
      },
    );
  }

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  PageController pageController;

  @override
  void initState() {
    super.initState();
    pageController = PageController(viewportFraction: 0.9);
  }

  @override
  void dispose() {
    super.dispose();
    pageController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: _HomePageContent(
          pageController: pageController,
        ),
      ),
    );
  }
}

我们的主页的实际内容将包含 CustomScrollViewPageViewSliverGrid 。页面视图会显示当前推流的列表,之前的直播推流则显示在条形网格中。为了区分不同部分,我们可以使用带有大标题的 CupertinoSliverNavigationBar

class _HomePageContent extends StatelessWidget {
  const _HomePageContent({
    Key key,
    @required this.pageController,
  })  : assert(pageController != null),
        super(key: key);
  final PageController pageController;

  void onFeatureCardPressed(BuildContext context, Video item) {
    // TODO: Configure Channel
    Navigator.of(context).push(
      PlayerPage.route(item.playbackUrl),
    );
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: () async {
					// TODO: Handle refresh
      },
      child: CustomScrollView(
        slivers: [
          CupertinoSliverNavigationBar(
            largeTitle: Text(
              "Live Flutter",
              style: GoogleFonts.inter(
                color: Colors.black,
                fontWeight: FontWeight.w700,
              ),
            ),
          ),
          SliverToBoxAdapter(
            child: SizedBox(
              height: 200.0,
              child: PageView.builder(
                controller: pageController,
                itemCount: 3,
                itemBuilder: (BuildContext context, int index) {
                  return FeaturedStreamCard(
                    thumbnailUrl: "https://source.unsplash.com/random/1920x1080",
                    onTap: () => onFeatureCardPressed(context, item),
                  );
                },
              ),
            ),
          ),
          SliverPadding(
            padding: const EdgeInsets.only(left: 24.0, top: 42.0),
            sliver: SliverToBoxAdapter(
              child: Text(
                "Browse",
                style: GoogleFonts.inter(
                  color: Colors.black,
                  fontSize: 32.0,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ),
          ),
          SliverPadding(
            padding: const EdgeInsets.only(top: 20.0, left: 12.0, right: 12.0),
            sliver: SliverGrid(
                  delegate: SliverChildBuilderDelegate(
                    (context, index) {
                      return FeaturedStreamCard(
                        thumbnailUrl: "https://source.unsplash.com/random/1920x1080",
                        onTap: () => onFeatureCardPressed(context, item),
                      );
                    },
                    childCount: 4,
                  ),
                  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 2,
                    childAspectRatio: 1.7,
                    crossAxisSpacing: 12.0,
                 ),
             ),
          ),
        ],
      ),
    );
  }
}

最后,可以使用简单的 AspectRatio 和一个卡片来执行 FeaturedStreamCard 微件。在缩放和调整微件时, AspectRatio 对于保持图像比例非常有用。

class FeaturedStreamCard extends StatelessWidget {
  const FeaturedStreamCard({
    Key key,
    @required this.onTap,
    this.thumbnailUrl,
  }) : super(key: key);
  final VoidCallback onTap;
  final String thumbnailUrl;

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: 16 / 9,
      child: Card(
        clipBehavior: Clip.hardEdge,
        child: InkWell(
          onTap: onTap,
          child: Image.network(
            thumbnailUrl,
            errorBuilder: (context, _, __){
              return Image.asset("assets/logo.png");
            },
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }
}

综合所有内容,我们可以在之前屏幕中配置导航器 LandingPage ,通过执行 onContinueToHomePressed 函数将其路由到新屏幕。

Future<void> onContinueToHomePressed() async {
  if (formKey.currentState.validate()) {
    Navigator.of(context).pushReplacement(HomePage.route());
  }
  return;
}

视频页面布局:video_camera:

0_3Hd841r0N-2p65tk

应用程序的最后一个屏幕是最重要的。毕竟对于直播推流应用来说,没有视频播放器和聊天窗口还有什么用?:stuck_out_tongue_closed_eyes:

播放器的设计包含两个不同的元素:播放器本身和底部的聊天列表。现在,我们先关注播放器。大家应该记得,我们在最开始的时候在项目上添加了一些软件包,其中一个软件包是视频播放器插件 yoyo_player 。我选择这个程序包的原因是它有好用 UI 并且支持 HLS / RTMP 推流。但大家可以根据需要使用官方 video_player 软件包。

class PlayerPage extends StatefulWidget {
  const PlayerPage({
    Key key,
    @required this.streamUrl,
  }) : super(key: key);

  static Route<dynamic> route(String url) {
    return MaterialPageRoute<dynamic>(
      builder: (BuildContext context) {
        return PlayerPage(streamUrl: url);
      },
    );
  }

  final String streamUrl;

  @override
  _PlayerPageState createState() => _PlayerPageState();
}

class _PlayerPageState extends State<PlayerPage> {

  @override
  void initState() {
    super.initState();
    SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.white,
        leading: BackButton(
          color: Colors.black,
        ),
        title: Text(
          "Video",
          style: GoogleFonts.inter(color: Colors.black),
        ),
      ),
      body: SafeArea(
        child: Column(
          children: [
            Expanded(
              flex: 3,
              child: YoYoPlayer(
                aspectRatio: 16 / 9,
                url: widget.streamUrl,
                videoStyle: VideoStyle(),
                videoLoadingStyle: VideoLoadingStyle(),
              ),
            ),
            Expanded(
              flex: 4,
              child: Container(), // TODO: Replace with chat
            )
          ],
        ),
      ),
    );
  }
}

大家应该能猜到,我们会用一个 Column 和两个 Expanded 来创建布局。目前,我只创建了视频播放器,稍后我们将讨论如何聊天窗口。

后端设置:hammer_and_wrench:

跟其他应用一样,我们要为应用创建一个后端。本示例的后端指的是负责与外部服务进行通信的应用程序层。由于教程中使用的是 MuxStream,因此我们会创建一个简单的后端,来实现应用所需的功能。

首先, 我们配置 Mux 后端。实现此后端所需的代码最少,只需要执行两个函数: fetchPastLivestreamsfetchLivestreams 。这两个函数都各返回一个对象列表,我们可以创建一个简单模型,将两个对象列表转换为一个对象。

:bulb:注意:我们正在将 Mux API 字符串传递给类。这是一个非常简单的 api,它向 mux 查询当前正在进行的和之前的直播推流。大家可以在这里获得示例 api 。比较推荐的方法是在运行时使用 config 文件中的 Dart Define 将这些值传递给应用。

类:

import 'dart:convert' show jsonDecode;
import 'dart:developer' show log;

import 'package:flutter/cupertino.dart';
import 'package:hfs/models/video_model.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

@immutable
class MuxBackend {
  MuxBackend({
    @required this.client,
    @required this.muxApi,
  }) : assert(client != null);

  final http.Client client;
  final String muxApi;

  Future<List<Video>> fetchPastLivestreams() async {
    try {
      final http.Response response = await http.get(
        "$muxApi/assets",
      );
      final data = jsonDecode(response.body) as List<dynamic>;
      return data.map((item) => Video.fromMap(item)).toList(growable: false);
    } catch (error) {
      log("fetchPastLivestreams: ${error.toString()}");
      throw Exception("Past livestream recordings are currently unavailable");
    }
  }

  Future<List<Video>> fetchLivestreams() async {
    try {
      final http.Response response = await http.get(
        "$muxApi/live-streams",
      );
      final data = jsonDecode(response.body) as List<dynamic>;
      return data
          .where((data) => data["status"] == "active")
          .map((item) => Video.fromMap(item))
          .toList(growable: false);
    } catch (error) {
      log("fetchLivestreams: ${error.toString()}");
      throw Exception("Live streams are currently unavailable");
    }
  }
}

模型:

class Video {
  Video._({
    @required this.playbackId,
    @required this.assetId,
    @required this.duration,
    @required this.createdAt,
    @required this.playbackUrl,
    @required this.thumbnailUrl,
  });

  factory Video.fromMap(Map<String, dynamic> map) {
    final playback = List.from(map['playback_ids']).first['id'];
    return Video._(
      playbackId: playback,
      assetId: map['id'] as String,
      duration: map['duration'] != null
          ? Duration(seconds: (map['duration'] as double).toInt())
          : null,
      createdAt: DateTime.parse(map['created_at']),
      playbackUrl: "https://stream.mux.com/$playback.m3u8",
      thumbnailUrl:
          "https://image.mux.com/$playback/thumbnail.png?width=1920&height=1080&fit_mode=pad",
    );
  }

  @override
  String toString() {
    return 'Video{playbackUrl: $playbackUrl, playbackId: $playbackId, '
        'assetId: $assetId, duration: $duration, createdAt: $createdAt '
        'thumbnailUrl: $thumbnailUrl'
        '}';
  }

  final String playbackUrl;
  final String thumbnailUrl;
  final String playbackId;
  final String assetId;
  final Duration duration;
  final DateTime createdAt;
}

:bulb:Mux 会为我们的视频生成缩略图!我们可以把视频的回放 ID 和图像 URL 合并,从而访问这些缩略图。

接下来,是时候配置 Stream 来处理我们的聊天了。

Stream 概述:bulb:

Stream 提供了易于集成的聊天平台,而且有多种语言的客户端 SDK,旨在帮助用户将高性能、低延迟的聊天功能无缝集成到应用中,而且还能减少设置,降低成本。

在本文的示例中,Stream 已经被设置为支持直播推流的预定义频道。大家可以点击我们之前创建的项目的 Stream 仪表板,查看、定制这些设置。

:zap:getstream.io > project > chat > overview > channel types

0_9qtPIxUYcGCwP3Q2

执行 Stream 后端:hammer_and_wrench:

执行 Stream 后端与执行 Mux 后端非常相似。在示例中,我们有生成令牌及配置用户和频道的三个函数。因为大家可能对一些 Stream 术语不熟悉,所以我们可以将频道视为包含给定对话的所有消息的框。频道通常只有一种类型(在本示例中为“直播”)和一个 ID。

大家可能会注意到,在下面的示例中创建的频道有从视频 URL 生成的 ID。有了这个ID, 如果我们向朋友分享视频直播,他们都可以进入到同一个频道里。

@immutable
class StreamBackEnd {
  StreamBackEnd({
    @required this.client,
    @required this.httpClient,
  }) : assert(client != null);

  final Client client;
  final http.Client httpClient;

  Future<String> getUserToken({@required String name}) async {
    assert(name != null);
    try {
      final http.Response response = await httpClient.post(
        "https://stream-token-api-elkxvkzduq-ue.a.run.app/token",
        body: {
          "name": name,
        },
      );
      return jsonDecode(response.body)['token'];
    } catch (error) {
      log("getUserToken: ${error.toString()}");
      throw Exception(
        "Stream SDK unable to generate token for $name",
      );
    }
  }

  Future<void> configureUser({
    @required String name,
    @required String id,
  }) async {
    try {
      final token = await getUserToken(name: name);
      await client.setUser(
        User(
          id: name,
          extraData: {
            "name": name,
            'image': 'https://getstream.io/random_png/?name=$name',
          },
        ),
        token,
      );
    } catch (exception) {
      log(exception.toString());
      throw Exception(
        "Stream SDK cannot set user with ID $id",
      );
    }
  }

  Future<Channel> configureChannel({@required final String url}) async {
    final id = _generateMd5(url);
    try {
      final channel = client.channel('livestream', id: id);
      channel.watch();
      return channel;
    } catch (exception) {
      log(exception.toString());
      throw Exception(
        "Stream SDK cannot create channel for ID $id",
      );
    }
  }

  String _generateMd5(String input) {
    return md5.convert(utf8.encode(input)).toString();
  }
}

我个人比较喜欢把各种服务/后端组合到一个 Backend 类中。

我们可以创建一个创建类的初始化程序。

import 'package:hfs/backend/mux_backend.dart';
import 'package:hfs/backend/stream_backend.dart';
import 'package:hfs/config.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

@immutable
class Backend {
  final StreamBackEnd streamBackEnd;
  final MuxBackend muxBackend;

  const Backend._({
    @required this.streamBackEnd,
    @required this.muxBackend,
  });

  static Backend init() {
    final String muxApiKey = EnvironmentConfig.muxApi;
    final String streamApiKey = EnvironmentConfig.streamAPIKey;

    final httpClient = http.Client();

    final stream = StreamBackEnd(
      httpClient: httpClient,
      client: Client(
        streamApiKey,
        logLevel: Level.SEVERE,
      ),
    );
    final mux = MuxBackend(
      client: httpClient,
      muxApi: muxApiKey,
    );

    return Backend._(
      streamBackEnd: stream,
      muxBackend: mux,
    );
  }
}

最后,我们可以在主要函数中初始化后端,将其传递给应用程序。

void main() {
  WidgetsFlutterBinding.ensureInitialized(); 
  SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
 
  final Backend backend = Backend.init();
  runApp(MyApp());
}

状态管理:gear:

最后,进入开发 Flutter 应用最令人兴奋的环节:选择状态管理模式。:smile:

我一般都没什么创意地使用大家常用的 bloc 软件包来处理应用程序。说的更具体点,我会使用 cubit ,因为它更好用并且设置少。

通过检查应用程序的用例和功能,我们可以确定 cubit 的一些候选对象:

  • 用户管理

  • 存档视频

  • 视频直播

  • 频道

我不打算详细介绍每个 cubit 的执行,但总体流程与下面的示例类似:

Cubit:

class LivestreamCubit extends Cubit<LivestreamState> {
  LivestreamCubit({@required this.muxBackend}) : super(LivestreamInitial());
  final MuxBackend muxBackend;

  Future<void> loadStreams() async {
    try {
      emit(DataLiveStreamState(isLoading: true));
      List<Video> videos = await muxBackend.fetchLivestreams();
      emit(DataLiveStreamState(videos: videos));
    } catch (error) {
      emit(
        DataLiveStreamState(
          hasError: true,
          error: "Livestream could not be loaded",
        ),
      );
    }
  }
}

状态:

@immutable
abstract class LivestreamState extends Equatable {
  const LivestreamState({
    @required this.isLoading,
    @required this.hasError,
    @required this.error,
  });

  final bool isLoading;
  final bool hasError;
  final String error;

  @override
  List<Object> get props => [
        isLoading,
        hasError,
        error,
      ];
}

class LivestreamInitial extends LivestreamState {}

class DataLiveStreamState extends LivestreamState {
  DataLiveStreamState({
    bool isLoading = false,
    bool hasError = false,
    this.videos,
    String error,
  })  : this.videoCount = videos?.length,
        super(
          isLoading: isLoading,
          hasError: hasError,
          error: error,
        );

  final List<Video> videos;
  final int videoCount;

  @override
  List<Object> get props => [...super.props, videos];
}

要查看每个块的实现,可以查看 GitHub 上的资源库:

Nash0x7E2/live-flutter

This repo contains code for my tutorial on adding live streaming video with Mux and Stream Chat to a Flutter…
github.com

执行 cubits 后,我们就可以使用 BlocProvider 进行注册,在应用中使用。

我们删除main.dart 中默认的 MyApp 窗口微件,创建一个新的 Stateless 微件。在这个微件中,我们将为应用创建材料应用并注册 bloc provider。

class HLSPOC extends StatelessWidget {
  const HLSPOC({
    Key key,
    @required this.backend,
  })  : assert(backend != null),
        super(key: key);

  final Backend backend;

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(
          create: (context) => UserCubit(backend: backend.streamBackEnd),
        ),
        BlocProvider(
          create: (context) => ChannelCubit(backend: backend.streamBackEnd),
        ),
        BlocProvider(
          create: (context) => ArchivedVideosCubit(
            muxBackend: backend.muxBackend,
          )..getArchivedVideos(),
        ),
        BlocProvider(
          create: (context) => LivestreamCubit(
            muxBackend: backend.muxBackend,
          )..loadStreams(),
        ),
      ],
      child: StreamChat(
        client: backend.streamBackEnd.client,
        child: MaterialApp(
          debugShowCheckedModeBanner: false,
          home: LandingPage(),
        ),
      ),
    );
  }
}

为了方便起见,可以创建一个 MultiBlocProvider 来注册所有这四个 cubit 类。最后,可以在 StreamChat 中封装 MaterialApp ,并将之前创建的 Stream 客户端作为必需参数传递。

:bulb:你知道使用 Dart 的级联表示法创建一个 cubit

后可以立即触发一个函数吗?我们使用这种格式来加载初始的实时视频和存档视频。

合并:zap:

太棒啦!直播推流项目最后一步是用我们创建的 cubit 和后端的实时数据来替换应用程序中的静态内容。

我们从最简单的部分开始——更新登录页面。首先,修改 onPress 功能来配置用户和频道,然后导航到主页。

Future<void> onContinueToHomePressed() async {
    if (formKey.currentState.validate()) {
      await context.read<UserCubit>().configureUser(name: nickname); //new
      Navigator.of(context).pushReplacement(HomePage.route());
    }
    return;
  }

  Future<void> onCustomUrlGoPressed() async {
    if (formKey.currentState.validate()) {
      await context.read<UserCubit>().configureUser(name: nickname); //new
      context.read<ChannelCubit>().configureChannel(url); // new
      Navigator.of(context).pushReplacement(
        PlayerPage.route(url),
      );
    }
  }

注意:在配置用户时,我们在 onCustomUrlGoPressed 中使用 await 。这是因为 Stream SDK 要求在配置频道之前必须具有活跃的 WebSocket 连接。

接下来,可以通过替换 build 内容来更新 HomePage

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: BlocBuilder<UserCubit, CubitStreamState>(
          builder: (context, state) {
            if (state is StreamUserState && state.hasData) {
              return _HomePageContent(
                pageController: pageController,
              );
            } else if (state is StreamUserState && state.isLoading) {
              return Center(
                child: SizedBox(
                  height: 100.0,
                  width: 100.0,
                  child: CircularProgressIndicator(),
                ),
              );
            } else if (state is StreamUserState && state.hasError) {
              return Center(
                child: Text("We are having some problems loading videos :("),
              );
            } else {
              return SizedBox();
            }
          },
        ),
      ),
    );
  }

在这里,当状态正在加载时,我们会显示一个加载器,当发生错误时,我们会显示 Text

接下来,可以继续到 _HomePageContent 执行直播推流和存档文件。

111
222
333
444
555

最后,我们可以用一个 StreamChannel 的示例来替换视频播放器页面中的临时容器。这是 Stream SDK 提供的微件,可以显示消息列表。推流频道需要两个参数:一个频道和一个子频道。推流频道的子级的作用是显示频道中的消息。

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.white,
        leading: BackButton(
          color: Colors.black,
        ),
        title: Text(
          "Video",
          style: GoogleFonts.inter(color: Colors.black),
        ),
      ),
      body: SafeArea(
        child: Column(
          children: [
            Expanded(
              flex: 3,
              child: YoYoPlayer(
                aspectRatio: 16 / 9,
                url: widget.streamUrl ?? url,
                videoStyle: VideoStyle(),
                videoLoadingStyle: VideoLoadingStyle(),
              ),
            ),
            Expanded(
              flex: 4,
              child: BlocBuilder<ChannelCubit, CubitChannelState>(
                builder: (BuildContext context, state) {
                  if (state is DataChannelState && state.channel != null) {
                    return StreamChannel(
                      channel: state.channel,
                      child: ChannelPage(),
                    );
                  } else if (state is DataChannelState && state.isLoading) {
                    return Center(
                      child: SizedBox(
                        height: 100.0,
                        width: 100.0,
                        child: const CircularProgressIndicator(),
                      ),
                    );
                  } else if (state is DataChannelState && state.hasError) {
                    return Center(
                      child: Text("Oh no, we can't load comments right now :/"),
                    );
                  } else {
                    return const SizedBox();
                  }
                },
              ),
            )
          ],
        ),
      ),
    );
  }
}

class ChannelPage extends StatelessWidget {
  const ChannelPage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Expanded(
          child: MessageListView(),
        ),
        MessageInput(),
      ],
    );
  }
}

终于完成了!现在运行应用程序,使用 Larix 应用开始直播!

恭喜啦!

本文介绍了很多内容,从直播推流的内部工作原理到使用 Flutter 构建简单的应用程序。

尽管内容很多,但却仍旧只是冰山一角,如果想了解更多信息或想试着搭建自己的项目,可以查看我的 Github

另外,大家可以点击查看 MuxStream ,了解有关直播推流和实时聊天的更多信息(这两种服务都可以免费试用哦),希望大家可以试着创建自己的 Flutter 直播应用。

感谢阅读!

原文作者 Nash
原文链接 https://medium.com/flutter-community/live-streaming-with-mux-stream-and-flutter-2c03d581b1b

推荐阅读
相关专栏
前端与跨平台
90 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。