现在直播推流非常流行。无论是时下流行的游戏网站(比如 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 基于互联网的传输控制协议(俗称 TCP),默认使用端口 1935 进行通信。后来,该协议更新,增加了传输层安全性协议(TLS)支持的 RTMPS 和由 Adobe 开发的专有加密形式 RTMPE。
如今,RTMP 不再像以前那样占主导地位,但仍广泛应用于推流服务器,在直播推流周期的开端从源头提取原始视频。随后,推流应用会对视频进行编码、压缩和重新打包以便更好地适应终端设备。
基本的推流流程
流程步骤
大家经常通过自己的设备观看喜欢的电视和体育节目,但其实原始视频必须经过好几个步骤,才能到达用户设备。
首先,用摄像机或数字录像机拍摄原始视频。原始输入可能会非常大,不适合通过网络传输。为了让输入内容不占用太多内存,同时易于访问,就给原始视频编码并压缩到一个解码器中(例如 H.264,VP8)。可根据用户的需求来选择,但 H.264 是视频编码的首选。
接下来,使用推流协议将编码的视频分发到媒体服务器上。最近最受欢迎的推流协议是 RTMP,大家也可以使用其他协议,如 MPEG-DASH 或安全可靠传输协议(SRT)。
然后,将这些协议创建的推流发送到媒体服务器,在媒体服务器上对其进行转码、调整大小、并拆分为不同的分辨率和格式,以交付给最终用户。大多数情况下,推流会被重新打包为各种形式的质量和比特率,以更好地为其他互联网连接的用户提供服务,这个过程被称为“ transmuxing”。
最后,使用诸如 MPEG-DASH 或 Apple 的 HLS 之类的方法将推流发送给终端用户,这是两种使用最广泛且兼容性最好的直播推流方法。另外,大家经常通过内容交付网络或 CDN 分发推流,从而减少延迟,降低推流服务器负载。
实践推流
构建一个端到端的推流平台绝非易事。首先创建和维护端到端的流程所涉及的技术极其复杂,除此之外,还要开发和扩展流程,要在不同区域内维护服务器,进而实现低延迟播放,这样就增加了时间和成本。
还好这是某些公司和服务的强项。Mux 等推流平台支持把视频直播集成到开发人员或企业的应用中,从而让应用快速上市,扫除了一些视频直播应用开发会遇到的技术/经济障碍。
本文创建的推流应用就打算用 Mux 和 Stream 把视频直播和聊天功能集成到应用中。
我们即将搭建的应用的一些目标:
-
播放自定义 HLS 和 RTMP 推流
-
显示之前点播的推流的存档
-
视频下方的实时消息和聊天
项目设置
我们将使用 Mux 和 Stream 处理视频推流和实时消息,可以在 Mux 和 Stream 上创建免费帐户来获取 API 密钥。下面示例用的是 Mux,在 Mux 的首页输入电子邮件后或收到一封邮件,按照邮件内的说明进行操作。
使用 Stream 步骤跟 Mux 类似:输入用户名、电子邮件和密码。
然后,在 Mux 上创建一个视频推流测试项目,确保一切正常。找到屏幕左侧菜单,点击 video 选项下的子类别“Live Streams”。
我们可以直接在仪表板查看正在进行的推流,也可以创建新的推流。因为我们要进行测试,所以选择右上角的“Create New Live Stream”。
我们会看到一个控制台,可以在控制台上创建新的直播推流。因为我们用的是免费帐户,所以会有一条提示信息,提示我们的推流限制在 5 分钟之内。但是,此限制只限制视频的长度,并不限制对其他功能的使用。推流结束后,我们可以对直播推流的各个方面进行自定义,例如推流和资产的隐私设置。我们还可以为推流设置许多选项和配置。如果想了解更多关于选项的信息,我强烈建议大家查看 Mux 文档的入门指南。
运行请求后,会看到类似于下图的响应。一定要关注密钥是 stream_key
和 playback_ids.id
,这两项稍后分别用于发布和查看我们的推流。
注意:切勿公布流密钥,此值应始终设为私密。
最后,可以通过单击侧边菜单中的直播推流选项或底部的“View Live Stream”选项来查看新创建的推流的详细信息。
此页面包含当前推流的信息。在我们的示例中,由于我们没有正在直播,因此推流被显示为“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允许通过 WiFi 实时从你的移动设备直播推流视频或音频…
apps.apple.com
在 Larix 中,我们可以用 URL 创建一个新的连接 rtmps://global-
live.mux.com:443/app/<YOUR-STREAM-KEY>
。我还用我的 Mux 凭证进行了 RTMP 认证,但这个步骤可以省略。
连接保存后,我们可以点击 Larix 上的红色“record”按钮开始直播。
要验证推流是否正常运行,请尝试刷新直播推流预览页面或访问 URL https://stream.mux.com/YOUR-PLAYBACK-ID.m3u8
。
恭喜,你已成功迈出了构建视频直播应用的第一步。接下来是构建应用程序的框架!
创建应用布局
首先,新建一个 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
为简单起见,我们的应用有三个屏幕。第一个屏幕是所有用户启动应用时看到的登录页面,用户可以在登录页面输入自定义 URL 和昵称,与朋友一起观看视频直播,也可以直接转到应用的主页上查看当前和过去的直播推流列表。
另外,我们将视频回放屏幕分为两部分,顶部是视频播放器,底部是实时聊天。
编写登录页面
登录页面布局由一些小部件组成,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,
),
),
),
);
}
},
),
],
),
),
),
);
}
你知道可以使用 ShaderMaskover 微件来应用渐变吗?请注意,在上面的代码中,我们使用 ShaderMask 在登录页面图标上应用了渐变。
接下来,我们创建主页的布局~
主页布局
主页设置有点复杂。为了便于管理,我选择使用两个单独的微件。
第一个微件包含一个静态方法,用于简化页面控制器的导航和初始化。稍后,我们将集成 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,
),
),
);
}
}
我们的主页的实际内容将包含 CustomScrollView
、 PageView
和 SliverGrid
。页面视图会显示当前推流的列表,之前的直播推流则显示在条形网格中。为了区分不同部分,我们可以使用带有大标题的 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;
}
视频页面布局
应用程序的最后一个屏幕是最重要的。毕竟对于直播推流应用来说,没有视频播放器和聊天窗口还有什么用?
播放器的设计包含两个不同的元素:播放器本身和底部的聊天列表。现在,我们先关注播放器。大家应该记得,我们在最开始的时候在项目上添加了一些软件包,其中一个软件包是视频播放器插件 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
来创建布局。目前,我只创建了视频播放器,稍后我们将讨论如何聊天窗口。
后端设置
跟其他应用一样,我们要为应用创建一个后端。本示例的后端指的是负责与外部服务进行通信的应用程序层。由于教程中使用的是 Mux 和 Stream,因此我们会创建一个简单的后端,来实现应用所需的功能。
首先, 我们配置 Mux 后端。实现此后端所需的代码最少,只需要执行两个函数: fetchPastLivestreams
和 fetchLivestreams
。这两个函数都各返回一个对象列表,我们可以创建一个简单模型,将两个对象列表转换为一个对象。
注意:我们正在将 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;
}
Mux 会为我们的视频生成缩略图!我们可以把视频的回放 ID 和图像 URL 合并,从而访问这些缩略图。
接下来,是时候配置 Stream 来处理我们的聊天了。
Stream 概述
Stream 提供了易于集成的聊天平台,而且有多种语言的客户端 SDK,旨在帮助用户将高性能、低延迟的聊天功能无缝集成到应用中,而且还能减少设置,降低成本。
在本文的示例中,Stream 已经被设置为支持直播推流的预定义频道。大家可以点击我们之前创建的项目的 Stream 仪表板,查看、定制这些设置。
:getstream.io > project > chat > overview > channel types
执行 Stream 后端
执行 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());
}
状态管理
最后,进入开发 Flutter 应用最令人兴奋的环节:选择状态管理模式。
我一般都没什么创意地使用大家常用的 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 上的资源库:
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 客户端作为必需参数传递。
你知道使用 Dart 的级联表示法创建一个 cubit
后可以立即触发一个函数吗?我们使用这种格式来加载初始的实时视频和存档视频。
合并
太棒啦!直播推流项目最后一步是用我们创建的 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
执行直播推流和存档文件。
最后,我们可以用一个 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。
另外,大家可以点击查看 Mux 和 Stream ,了解有关直播推流和实时聊天的更多信息(这两种服务都可以免费试用哦),希望大家可以试着创建自己的 Flutter 直播应用。
感谢阅读!
原文作者 Nash
原文链接 https://medium.com/flutter-community/live-streaming-with-mux-stream-and-flutter-2c03d581b1b