Flutter 撸一个无人直播推流+短视频去水印项目
运用技术:
·Flutter编写安卓和IOS端
·JAVA编写后端逻辑
·PHP编写视频解析逻辑
·VUE编写解析视频网页端
·sh脚本 ffmpeg视频解码转码推流
体验地址(firefix.cn)
应用介绍:最近在研究自媒体,发现抖音上面一些主播24小时不休息一直在播放几部电影,发现我们熬夜在看直播,主播其实已经在睡觉,我们看到的人不一定是真人,方便自己做自媒体 开发了TobeSaver 功能如下
1.无需真人出镜,也不需要电脑和手机 提前录制好的视频素材通过云服务器推流直播
2.视频图片去水印下载 支持抖音、快手、西瓜视频、TikTok、YouTobe、bilibili、小红书、微博、等180个平台。
适配打包windows端
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();
}
print(">>>>>>>>>>>>>>>${respond}");
}
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后端 springboot全家桶 服务端处理简单业务
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
3.php视频解析去水印下载适配(参考Images_spider做了进一步适配)
3.vue 编写简单的网页版解析视频下载
<template>
<div id="home" @mousewheel="handleMouseWheel" v-swipeup="showCenter">
<!-- 遮罩:防止用户在动画播放期间点击屏幕 -->
<div
class="mask_ban_touch"
v-if="!flag"
style="width: 100%; height: 100%; z-index: 999; position: absolute"
></div>
<div
:style="{ background: `url(${imgUrl})` }"
:class="[{ wrapper_blur: centerShow }, 'wrapper', 'bg-blur']"
>
<div :class="['img_shadow', { img_shadow_show: imgLoded }]"></div>
<div
style="display: flex;flex-direction: column;position: absolute;z-index: 1000;top: 11rem;align-items: center;left: 0;right: 0;padding-left: 1rem;padding-right: 1rem;">
<div
style="height:3.6rem;display: flex;flex-direction: row;width: 100%;max-width: 60rem">
<input
style="flex: 1;border: none;outline: none;padding-left: 10px;padding-right: 10px;background-color: white;border-top-left-radius: 8px;border-bottom-left-radius: 8px; font-size: 1rem"
placeholder="请将复制的链接粘贴到这里"
v-model="inputValue">
<div
style="background-color: #7776FF; border: none;border-color: transparent; border-top-right-radius: 8px;border-bottom-right-radius: 8px;padding-left: 15px;padding-right: 15px;display: flex;align-items: center;justify-content: center">
<button style="background-color: transparent;border: none;font-size:1rem;color: white" @click="parse"
v-if="!isLoading">
解析视频
</button>
<pulseloader :loading="isLoading" color="#ffffff" size="10px"></pulseloader>
</div>
</div>
<div
style="background-color: #eeeeee;display: flex;flex-direction: column;align-items: center;padding: 10px;border-radius: 8px;width: 100%;max-width: 59rem;margin: 1rem"
v-if="resultData">
<p>{{ resultData.desc }}</p>
<div style="display: flex;align-items: center;flex-direction: row">
<button
style="background-color: green;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px;margin-right: 15px"
v-if="resultData.playAddr" @click="downloadVideo">下载视频
</button>
<button
style="background-color: deepskyblue;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px;margin-right: 15px"
v-if="resultData.cover" @click="downloadCover">下载封面
</button>
<button
style="background-color: #2196F3;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px"
v-if="resultData.music" @click="downloadVoice">下载音频
</button>
</div>
<div v-if="resultData.pics">
<button
style="background-color: green;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px;margin-right: 15px;margin-top: 10px"
@click="downloadPic(item)" v-for="(item,index) in resultData.pics" :key="index">下载图片{{ index + 1 }}
</button>
</div>
</div>
</div>
<!-- <div class="inner" style="cursor: pointer" @click="goToBlog">-->
<!-- <img-->
<!-- :class="['R_logo', { R_logo_top: flag }]"-->
<!-- src="../assets/logo.svg"-->
<!-- />-->
<!-- <div :class="['hello', { hello_bottom: flag }]">-->
<!-- <div>{{ slogan[i] }}</div>-->
<!-- <div class="hello_bottom_text">-->
<!-- 点击以访问 {{ $config.BLOG_NAME }}-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div>
<a></a>
<!-- 上下滑动指示器 -->
<div
:class="['bottom', { bottom_show: flag }]"
style="cursor: pointer"
@click="centerShow = !centerShow"
>
<div class="bottom-icon">
<transition name="fade">
<i v-if="!centerShow" class="mdi-chevron-up mdi"></i>
<i v-if="centerShow" class="mdi-chevron-down mdi"></i>
</transition>
</div>
<div class="bottom-info">Slide Up</div>
</div>
<!-- 备案号 -->
<a
class="record_number"
:class="{ record_number_show: flag }"
href="http://beian.miit.gov.cn/"
v-if="recordNumber"
>{{ recordNumber }}</a
>
<!-- 导航抽屉 -->
<transition name="fade">
<div class="shadow" v-show="centerShow"></div>
</transition>
<transition name="slide">
<div v-show="centerShow" class="center_wrapper" @click="hideCenter">
<div class="center_inner" @click="stopPropagation">
<center @hide="hideCenter" ref="center"></center>
</div>
</div>
</transition>
<toast ref="toast"></toast>
</div>
</template>
<script>
import center from "./center.vue";
import toast from "./toast.vue";
import pulseloader from "vue-spinner/src/PulseLoader.vue";
function randomNum(minNum, maxNum) {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * minNum + 1, 10);
case 2:
return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10);
default:
return 0;
}
}
export default {
name: "home",
data() {
this.startTime = new Date();
return {
flag: false, // 动画是否播放完毕
isLoading: false,
slogan: [],
i: 0,
centerShow: false, // 导航抽屉显示状态
imgLoded: false, // 背景图片加载状态
imgUrl: "",
resultData: null,
inputValue: ''
};
},
components: {
center,
toast,
pulseloader
},
computed: {
recordNumber() {
return this.$config.RECORD_NUMBER;
}
},
methods: {
goToBlog() {
window.location.href = this.$config.BLOG_URL;
},
_jieliu(callback, delay) {
let currentTime = new Date();
if (currentTime - this.startTime > delay) {
callback();
this.startTime = new Date();
}
},
parse() {
var url = /http[s]?://[\w.]+[\w/]*[\w.]*??[\w=&:-+%]*[/]*/.exec(this.inputValue.trim())
if (url != null && url.length > 0) {
let li = url[0];
if (li.includes(".html")) {
var endIndex = li.indexOf(".html") + 5;
li = li.substring(0, endIndex);
}
this.isLoading = true
this.resultData = null
new Promise((resolve, reject) => {
this.axios
.get("videoParse/web/parse?link=" + li)
.then(res => {
console.log(res);
if (res.data['code'] == 0) {
this.isLoading = false
this.resultData = res.data['data']
} else {
this.isLoading = false
this.$refs.toast.show(res.data['msg']);
if (li.includes("unwatermarker")) {
this.showCenter();
}
}
resolve();
})
.catch(err => {
this.isLoading = false
console.log(err);
reject(err);
});
});
} else {
this.$refs.toast.show("请输入正确的链接地址");
this.showCenter();
}
},
downloadVideo() {
this.downloadFile(this.resultData.playAddr, "video.mp4")
},
downloadPic(url) {
this.downloadFile(url, "image.jpg")
},
downloadCover() {
this.downloadFile(this.resultData.cover, "image.jpg")
},
downloadVoice() {
this.downloadFile(this.resultData.music, "music.mp3")
},
downloadFile(fileUrl, fileName) {
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
link.target = '_blank';
link.click();
},
handleMouseWheel(e) {
if (e.deltaY < 0) {
// 如果鼠标滚轮向上滚动
if (!this.centerShow) {
this.centerShow = true;
} else {
this._jieliu(() => {
this.$refs.center.scroller.scrollBy(
0,
100,
500,
"cubic-bezier(0.23, 1, 0.32, 1)"
);
}, 500);
}
} else {
// 如果鼠标滚轮向下滚动
if (!this.centerShow) {
return;
} else {
this._jieliu(() => {
this.$refs.center.scroller.scrollBy(
0,
-100,
500,
"cubic-bezier(0.23, 1, 0.32, 1)"
);
}, 500);
}
}
},
showCenter() {
this.centerShow = true;
},
hideCenter() {
this.centerShow = false;
},
stopPropagation(e) {
e.stopPropagation();
}
},
mounted() {
setTimeout(() => {
this.flag = true;
}, 1300);
// 图片懒加载
this.imgUrl = this.$config.BACKGROUND_IMG_URL;
var img = new Image();
img.src = this.imgUrl;
img.onload = () => {
this.imgLoded = true;
};
this.slogan = this.$config.SLOGAN;
this.i = randomNum(0, this.slogan.length - 1);
}
};
</script>
<style lang="scss" scoped>
#home {
height: 100%;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
overflow: hidden;
.github {
display: block;
position: absolute;
top: 10px;
right: 10px;
color: white;
font-size: 2rem;
z-index: 1;
cursor: pointer;
}
.wrapper {
background-size: cover !important;
overflow: hidden;
align-items: center;
justify-content: center;
display: flex;
border-radius: 100%;
animation: logoEnter 1.2s;
animation-fill-mode: forwards;
transition: all 0.8s;
.inner {
position: relative;
.R_logo {
height: 7rem;
position: absolute;
transform: translate(-50%, -50%);
transition: all 1s;
top: 0;
}
.R_logo_top {
top: -3.2rem;
}
.hello {
color: #ffffff;
width: 18.75rem;
text-align: center;
position: absolute;
transform: translate(-50%, -50%);
font-size: 1.5rem;
opacity: 0;
top: 100px;
transition: all 1s;
}
.hello_bottom {
opacity: 1;
top: 3.5rem;
.hello_bottom_text {
font-size: 1rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #fff;
}
}
}
}
.img_shadow {
content: "";
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background-color: #fda085;
overflow: hidden;
transition: all 0.5s;
}
.img_shadow_show {
background-color: rgba(0, 0, 0, 0.5);
}
.wrapper_blur {
filter: blur(1px);
}
.bottom {
font-size: 2rem;
position: absolute;
color: #fff;
bottom: 0%;
opacity: 0;
transition: all 1s;
text-align: center;
.bottom-info {
font-size: 1rem;
margin-top: 5px;
animation: float 4s infinite ease-in-out;
}
}
.bottom_show {
bottom: 50px;
opacity: 1;
}
.shadow {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
}
.center_wrapper {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
.center_inner {
width: 70%;
position: absolute;
overflow: hidden;
border-radius: 0.5rem;
left: 0;
right: 0;
margin: 0 auto;
bottom: 5%;
height: 70%;
}
}
.record_number {
width: 100%;
text-align: center;
color: #fff;
text-decoration: none;
font-size: 12px;
line-height: 30px;
background: rgba(0, 0, 0, 0.4);
position: fixed;
bottom: 0;
transform: translateY(30px);
transition: transform ease 1s;
}
.record_number_show {
transform: translateY(0px);
}
}
@media screen and (max-width: 900px) {
#home {
.center_wrapper {
.center_inner {
width: 100%;
bottom: 0px;
padding: 10px;
box-sizing: border-box;
}
}
}
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.8s ease;
}
.slide-enter,
.slide-leave-to {
transform: translateY(100%);
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.8s ease;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
@keyframes logoEnter {
0% {
opacity: 0;
width: 0rem;
height: 0rem;
}
20% {
opacity: 1;
width: 15rem;
height: 15rem;
}
80% {
transform: rotate(360deg);
width: 15rem;
height: 15rem;
}
100% {
transform: rotate(360deg);
width: 100%;
height: 100%;
border-radius: 0;
}
}
@keyframes float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
100% {
transform: translateY(0);
}
}
</style>