Flutter 撸一个无人直播推流项目
运用技术:
·Flutter编写安卓和IOS端
·JAVA编写后端逻辑
·PHP编写视频解析逻辑
·VUE编写解析视频网页端
·sh脚本 ffmpeg视频解码转码推流
应用介绍:最近在研究自媒体,发现抖音上面一些主播24小时不休息一直在播放几部电影,发现我们熬夜在看直播,主播其实已经在睡觉,我们看到的人不一定是真人,方便自己做自媒体 开发了TobeSaver 功能如下
1.无需真人出镜,也不需要电脑和手机 提前录制好的视频素材通过云服务器推流直播
1.Flutter直播推流页面
import 'package:downloaderx/utils/exit.dart';
import 'package:downloaderx/widget/live_type_item.dart';
import 'package:downloaderx/widget/platform_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import '../network/http_api.dart';
import '../network/http_utils.dart';
import '../utils/event_bus.dart';
import 'login_page.dart';
import 'tutorial_page.dart';
class PushStreamPage extends StatefulWidget {
const PushStreamPage({super.key});
@override
State<PushStreamPage> createState() => _PushStreamPageState();
}
class _PushStreamPageState extends State<PushStreamPage>
with SingleTickerProviderStateMixin {
List<dynamic> platform = [
{'title': '抖音', 'icon': 'douyin.png'},
{'title': '快手', 'icon': 'ks.png'},
{'title': '哔哩', 'icon': 'bili.png'},
{'title': '微博', 'icon': 'weibo.png'},
{'title': '知乎', 'icon': 'zhihu.png'},
// {'title': 'YouTobe', 'icon': 'youtobe.png'},
];
List<dynamic> liveType = [
{'title': '催眠直播', 'icon': 'iconspdy.png', 'type': 0},
{'title': '音乐直播', 'icon': 'iconspbilibili.png', 'type': 1},
{'title': '电影直播', 'icon': 'icon_sp_acfun.png', 'type': 2},
];
var currentPlatformIndex = 0;
var currentLiveIndex = 0;
bool isCircular = false;
int status = -1;
var countdown = 0;
var controllerHost = TextEditingController(text: "");
var controllerSecretKey = TextEditingController(text: "");
var controllerLiveUrl = TextEditingController(text: "");
@override
void initState() {
super.initState();
loadPushStreamInfo();
EventBus.getDefault().register(this, (event) {
if (event.toString() == "refresh_push_stream") {
loadPushStreamInfo();
}
});
}
@override
void dispose() {
super.dispose();
EventBus.getDefault().unregister(this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text(
"直播推流",
),
),
body: Container(
margin: EdgeInsets.symmetric(vertical: 20.w, horizontal: 40.w),
child: CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverToBoxAdapter(
child: Container(
alignment: Alignment.centerLeft,
margin: EdgeInsets.fromLTRB(0, 0, 0, 25.w),
child: Text(
"选择推流平台",
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 30.sp),
),
),
),
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10.w,
mainAxisSpacing: 10.w,
childAspectRatio: 2.2),
itemCount: platform.length,
itemBuilder: (BuildContext context, int index) {
return PlatFormItem(
item: platform[index],
isSelected: index == currentPlatformIndex,
onItemClick: onItemClick,
);
},
),
buildInputContainer("服务器地址:", '请输入服务器地址', controllerHost, context),
buildInputContainer(
"串流秘钥:", '请输入串流秘钥', controllerSecretKey, context),
buildInputContainer(
"直播间地址:", '请输入直播地址', controllerLiveUrl, context),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.centerLeft,
margin: EdgeInsets.fromLTRB(0, 0, 0, 25.w),
child: Text(
"直播类型",
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 30.sp),
),
),
),
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10.w,
mainAxisSpacing: 10.w,
childAspectRatio: 2.6),
itemCount: liveType.length,
itemBuilder: (BuildContext context, int index) {
return LiveTypeItem(
item: liveType[index],
isSelected: index == currentLiveIndex,
onItemClick: onLiveItemClick,
);
},
),
SliverToBoxAdapter(
child: Container(
margin: EdgeInsets.symmetric(vertical: 50.w, horizontal: 0),
child: Column(
children: [
GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: Duration(milliseconds: 600),
curve: Curves.linear,
width: isCircular ? 100.h : 600.w,
height: isCircular ? 100.h : 80.h,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(isCircular ? 50.h : 40.h),
color: Theme.of(context).primaryColor),
alignment: Alignment.center,
child: isCircular
? countdown > 0
? Stack(
alignment: Alignment.center,
children: [
LoadingAnimationWidget.threeArchedCircle(
color: Colors.white,
size: 60.h,
),
Text(
"${countdown}s",
style: TextStyle(color: Colors.white),
)
],
)
: LoadingAnimationWidget.hexagonDots(
color: Colors.white,
size: 60.h,
)
: Center(
child: Text(
status == -1
? '开始推流'
: status == 0
? "正在排队中..."
: "观看直播",
style: TextStyle(
color: Colors.white,
fontSize: 30.sp,
fontWeight: FontWeight.bold),
)),
// child: isCircular
// ? ClipOval(child: Container(color: Colors.blue))
// : null,
),
),
],
),
),
)
],
),
),
);
}
void onTap() async {
var host = controllerHost.value.text;
var secretKey = controllerSecretKey.value.text;
var liveUrl = controllerLiveUrl.value.text;
var plat = platform[currentPlatformIndex]['title'];
var type = liveType[currentLiveIndex]['type'];
if (await UserExit.isLogin() == null) {
Navigator.push(
context, MaterialPageRoute(builder: (context) => const LoginPage()));
return;
}
if (host.isEmpty) {
ToastExit.show('请输入服务器地址');
return;
}
if (secretKey.isEmpty) {
ToastExit.show('请输入秘钥地址');
return;
}
if (liveUrl.isEmpty) {
ToastExit.show('请输入直播地址');
return;
}
if (isCircular) {
return;
}
if (status == 0) {
ToastExit.show("正在排队推流中");
return;
} else if (status == 1) {
jumpLaunchUrl(liveUrl);
return;
}
setState(() {
countdown = 0;
isCircular = !isCircular;
});
var map = <String, dynamic>{};
map['liveHost'] = host;
map['secretKey'] = secretKey;
map['liveUrl'] = liveUrl;
map['platform'] = plat;
map['liveType'] = type;
var respond = await HttpUtils.instance.requestNetWorkAy(
Method.post, HttpApi.submitLiveStream,
queryParameters: map);
if (respond != null) {
await Future.delayed(Duration(milliseconds: 400));
status = 0;
startTimer();
}
}
void startTimer() {
ToastExit.show("已提交,正在排队等候推流中~");
countdown = 9;
Timer.periodic(const Duration(seconds: 1), (Timer timer) {
if (countdown == 1) {
timer.cancel();
setState(() {
isCircular = false;
status = 0;
});
} else {
setState(() {
countdown--;
});
}
});
}
void loadPushStreamInfo() async {
if (await UserExit.isLogin() != null) {
var respond = await HttpUtils.instance
.requestNetWorkAy(Method.get, HttpApi.getStreamInfo);
print(">>>>>>loadPushStreamInfo>>>>>>>>${respond}");
if (respond != null) {
setState(() {
controllerHost.text = respond['liveHost'];
controllerSecretKey.text = respond['secretKey'];
controllerLiveUrl.text = respond['liveUrl'];
status = respond['status'];
currentPlatformIndex = platform
.indexWhere((element) => element['title'] == respond['platform']);
currentLiveIndex = liveType
.indexWhere((element) => element['type'] == respond['liveType']);
});
}
}
}
Future<void> jumpLaunchUrl(webUrl) async {
final Uri uri = Uri.parse(webUrl);
if (!await launchUrl(uri)) {
throw Exception('Could not launch $uri');
}
}
void onItemClick(item) {
currentPlatformIndex = platform.indexOf(item);
setState(() {});
}
void onLiveItemClick(item) {
currentLiveIndex = liveType.indexOf(item);
setState(() {});
}
}
SliverToBoxAdapter buildInputContainer(String title, String hitText,
TextEditingController controller, BuildContext context) {
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
margin: EdgeInsets.fromLTRB(0, 25.w, 0, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TutorialPage()));
},
child: Row(
children: [
Text(
title,
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 30.sp),
),
const Icon(
Icons.help,
color: Colors.grey,
size: 16,
),
],
),
),
Container(
height: 80.w,
width: double.infinity,
child: TextField(
maxLines: 1,
keyboardType: TextInputType.url,
textAlignVertical: TextAlignVertical.center,
controller: controller,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: hitText,
hintStyle: const TextStyle(color: Colors.grey),
border: const OutlineInputBorder(borderSide: BorderSide.none),
focusedBorder:
const OutlineInputBorder(borderSide: BorderSide.none),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.symmetric(horizontal: 0.w),
),
),
),
],
),
),
);
}
2.java后端处理数据逻辑
3.sh脚本处理推流
e
w='\033[0;33m'
font="\033[0m"
ffmpeg_install(){
# 安装FFMPEG
read -p "你的机器内是否已经安装过FFmpeg4.x?安装FFmpeg才能正常推流,是否现在安装FFmpeg?(yes/no):" Choose
if [ $Choose = "yes" ];then
yum -y install wget
wget --no-check-certificate https://www.johnvansickle.com/ffmpeg/old-releases/ffmpeg-4.0.3-64bit-static.tar.xz
tar -xJf ffmpeg-4.0.3-64bit-static.tar.xz
cd ffmpeg-4.0.3-64bit-static
mv ffmpeg /usr/bin && mv ffprobe /usr/bin && mv qt-faststart /usr/bin && mv ffmpeg-10bit /usr/bin
fi
if [ $Choose = "no" ]
then
echo -e "${yellow} 你选择不安装FFmpeg,请确定你的机器内已经自行安装过FFmpeg,否则程序无法正常工作! ${font}"
sleep 2
fi
}
stream_start(){
# 定义推流地址和推流码
read -p "输入你的推流地址和推流码(rtmp协议):" rtmp
# 判断用户输入的地址是否合法
if [[ $rtmp =~ "rtmp://" ]];then
echo -e "${green} 推流地址输入正确,程序将进行下一步操作. ${font}"
sleep 2
else
echo -e "${red} 你输入的地址不合法,请重新运行程序并输入! ${font}"
exit 1
fi
# 定义视频存放目录
read -p "输入你的视频存放目录 (格式仅支持mp4,并且要绝对路径,例如/opt/video):" folder
# 判断是否需要添加水印
read -p "是否需要为视频添加水印?水印位置默认在右上方,需要较好CPU支持(yes/no):" watermark
if [ $watermark = "yes" ];then
read -p "输入你的水印图片存放绝对路径,例如/opt/image/watermark.jpg (格式支持jpg/png/bmp):" image
echo -e "${yellow} 添加水印完成,程序将开始推流. ${font}"
# 循环
while true
do
cd $folder
for video in $(ls *.mp4)
do
ffmpeg -re -i "$video" -i "$image" -filter_complex overlay=W-w-5:5 -c:v libx264 -c:a aac -b:a 192k -strict -2 -f flv ${rtmp}
done
done
fi
if [ $watermark = "no" ]
then
echo -e "${yellow} 你选择不添加水印,程序将开始推流. ${font}"
# 循环
while true
do
cd $folder
for video in $(ls *.mp4)
do
ffmpeg -re -i "$video" -c:v copy -c:a aac -b:a 192k -strict -2 -f flv ${rtmp}
done
done
fi
}
# 停止推流
stream_stop(){
screen -S stream -X quit
killall ffmpeg
}
# 开始菜单设置
echo -e "${yellow} CentOS7 X86_64 FFmpeg无人值守循环推流 For LALA.IM ${font}"
echo -e "${red} 请确定此脚本目前是在screen窗口内运行的! ${font}"
echo -e "${green} 1.安装FFmpeg (机器要安装FFmpeg才能正常推流) ${font}"
echo -e "${green} 2.开始无人值守循环推流 ${font}"
echo -e "${green} 3.停止推流 ${font}"
start_menu(){
read -p "请输入数字(1-3),选择你要进行的操作:" num
case "$num" in
1)
ffmpeg_install
;;
2)
stream_start
;;
3)
stream_stop
;;
*)
echo -e "${red} 请输入正确的数字 (1-3) ${font}"
;;
esac
}
# 运行开始菜单
start_menu
4. Vue简单视频解析网站
归纳总结:
此项目话费10个天时间,业余时间编写,搭建在2核4G的服务器上,一开始方便自己在自媒体上创作高质量视频,期间一段时间比较忙就没管了,后面不知什么时候被别人拿去用,有一天服务器发来了一封预警邮件,我一看cpu,内存高达99%,然后看了下数据库累计用户高达一万,网站日活高达300uv
整理一份相关文档供大家参考
创作不易,点关注不迷路!
技术链接彼此、代码拥抱未来!