feat: 简单完成视频播放

This commit is contained in:
guozhigq
2023-06-01 20:14:29 +08:00
parent d2cc94fb6f
commit 9b634ce303
21 changed files with 751 additions and 38 deletions

View File

@@ -37,7 +37,7 @@ class VideoCardH extends StatelessWidget {
child: InkWell(
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
Get.toNamed('/video?aid=$aid',
Get.toNamed('/video?aid=$aid&cid=${videoItem.cid}',
arguments: {'videoItem': videoItem, 'heroTag': heroTag});
},
child: Column(

View File

@@ -44,7 +44,7 @@ class VideoCardV extends StatelessWidget {
child: InkWell(
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
Get.toNamed('/video?aid=${videoItem.id}',
Get.toNamed('/video?aid=${videoItem.id}&cid=${videoItem.cid}',
arguments: {'videoItem': videoItem, 'heroTag': heroTag});
},
child: Column(

View File

@@ -6,6 +6,10 @@ class Api {
// 热门视频
static const String hotList = '/x/web-interface/popular';
// 视频流
// https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/video/videostream_url.md
static const String videoUrl = '/x/player/playurl';
// 视频详情
// 竖屏 https://api.bilibili.com/x/web-interface/view?aid=527403921
// https://api.bilibili.com/x/web-interface/view/detail 获取视频超详细信息(web端)

View File

@@ -59,6 +59,40 @@ class VideoHttp {
}
}
// 视频流
static Future videoUrl(
{int? avid, int? bvid, required int cid, int? qn}) async {
Map<String, dynamic> data = {
'avid': avid,
// 'bvid': bvid,
'cid': cid,
'qn': qn ?? 64,
// 'fnval': 16,
// 'fnver': '',
'fourk': 1,
// 'session': '',
// 'otype': '',
// 'type': '',
// 'platform': '',
// 'high_quality': ''
};
try {
var res = await Request().get(Api.videoUrl, data: data);
if (res.data['code'] == 0) {
// List<HotVideoItemModel> list = [];
// for (var i in res.data['data']['list']) {
// list.add(HotVideoItemModel.fromJson(i));
// }
return {'status': true, 'data': res.data['data']};
} else {
return {'status': false, 'data': []};
}
} catch (err) {
print('🐯:$err');
return {'status': false, 'data': [], 'msg': err};
}
}
// 视频信息 标题、简介
static Future videoIntro({required String aid}) async {
var res = await Request().get(Api.videoIntro, data: {'aid': aid});

View File

@@ -1,15 +1,18 @@
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_meedu_media_kit/meedu_player.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:pilipala/http/init.dart';
import 'package:pilipala/pages/video/detail/index.dart';
import 'package:pilipala/router/app_pages.dart';
import 'package:pilipala/pages/main/view.dart';
import 'package:pilipala/utils/storage.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
await GStrorage.init();
await Request.setCookie();
runApp(const MyApp());
@@ -53,6 +56,7 @@ class MyApp extends StatelessWidget {
getPages: Routes.getPages,
home: const MainApp(),
builder: FlutterSmartDialog.init(),
navigatorObservers: [VideoDetailPage.routeObserver],
);
}),
);

View File

@@ -43,6 +43,7 @@ class FavDetailItemData {
// this.season,
// this.ogv,
this.stat,
this.cid,
});
int? id;
@@ -62,6 +63,7 @@ class FavDetailItemData {
String? bvId;
String? bvid;
Stat? stat;
int? cid;
FavDetailItemData.fromJson(Map<String, dynamic> json) {
id = json['id'];
@@ -81,6 +83,7 @@ class FavDetailItemData {
bvId = json['bv_id'];
bvid = json['bvid'];
stat = Stat.fromJson(json['cnt_info']);
cid = json['ugc']['first_cid'];
}
}

View File

@@ -43,7 +43,7 @@ class FavVideoCardH extends StatelessWidget {
child: InkWell(
onTap: () async {
await Future.delayed(const Duration(milliseconds: 200));
Get.toNamed('/video?aid=$id',
Get.toNamed('/video?aid=$id&cid=${videoItem.cid}',
arguments: {'videoItem': videoItem, 'heroTag': heroTag});
},
child: Column(

View File

@@ -1,5 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_meedu_media_kit/meedu_player.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/constants.dart';
import 'package:pilipala/http/video.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/pages/video/detail/replyReply/index.dart';
@@ -9,7 +14,8 @@ class VideoDetailController extends GetxController {
RxList<String> tabs = <String>['简介', '评论'].obs;
// 视频aid
String aid = Get.parameters['aid']!;
int aid = int.parse(Get.parameters['aid']!);
int cid = int.parse(Get.parameters['cid']!);
// 是否预渲染 骨架屏
bool preRender = false;
@@ -30,6 +36,13 @@ class VideoDetailController extends GetxController {
final scaffoldKey = GlobalKey<ScaffoldState>();
MeeduPlayerController meeduPlayerController = MeeduPlayerController(
colorTheme: Theme.of(Get.context!).colorScheme.primary,
pipEnabled: true,
controlsStyle: ControlsStyle.youtube,
enabledButtons: const EnabledButtons(pip: true),
);
@override
void onInit() {
super.onInit();
@@ -43,16 +56,18 @@ class VideoDetailController extends GetxController {
}
heroTag = Get.arguments['heroTag'];
}
queryVideoUrl();
}
showReplyReplyPanel() {
PersistentBottomSheetController<void>? ctr = scaffoldKey.currentState?.showBottomSheet<void>((BuildContext context) {
PersistentBottomSheetController<void>? ctr =
scaffoldKey.currentState?.showBottomSheet<void>((BuildContext context) {
return VideoReplyReplyPanel(
oid: oid,
rpid: fRpid,
closePanel: ()=> {
closePanel: () => {
fRpid = 0,
},
},
firstFloor: firstFloor,
);
});
@@ -60,4 +75,37 @@ class VideoDetailController extends GetxController {
fRpid = 0;
});
}
playerInit(url) {
meeduPlayerController.setDataSource(
DataSource(
type: DataSourceType.network,
source: url,
httpHeaders: {
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
'referer': HttpString.baseUrl
},
),
autoplay: true,
looping: false
);
}
// Future<void> meeduDispose() async {
// if (meeduPlayerController != null) {
// _playerEventSubs?.cancel();
// await meeduPlayerController!.dispose();
// meeduPlayerController = null;
// // The next line disables the wakelock again.
// // await Wakelock.disable();
// }
// }
// 视频链接
queryVideoUrl() async {
var result = await VideoHttp.videoUrl(cid: cid, avid: aid);
var url = result['data']['durl'].first['url'];
playerInit(url);
}
}

View File

@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:extended_image/extended_image.dart';
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter_meedu_media_kit/meedu_player.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
@@ -7,30 +11,122 @@ import 'package:pilipala/pages/video/detail/controller.dart';
import 'package:pilipala/pages/video/detail/introduction/index.dart';
import 'package:pilipala/pages/video/detail/related/index.dart';
import 'package:pilipala/pages/video/detail/replyReply/index.dart';
import 'package:wakelock/wakelock.dart';
class VideoDetailPage extends StatefulWidget {
const VideoDetailPage({Key? key}) : super(key: key);
@override
State<VideoDetailPage> createState() => _VideoDetailPageState();
static final RouteObserver<PageRoute> routeObserver =
RouteObserver<PageRoute>();
}
class _VideoDetailPageState extends State<VideoDetailPage>
with TickerProviderStateMixin {
with TickerProviderStateMixin, RouteAware {
final VideoDetailController videoDetailController =
Get.put(VideoDetailController(), tag: Get.arguments['heroTag']);
MeeduPlayerController? _meeduPlayerController;
ScrollController _extendNestCtr = ScrollController();
late AnimationController animationController;
// final _meeduPlayerController = MeeduPlayerController(
// pipEnabled: true,
// controlsStyle: ControlsStyle.secondary,
// enabledButtons: const EnabledButtons(pip: true),
// );
StreamSubscription? _playerEventSubs;
bool isPlay = false;
bool isShowCover = true;
double doubleOffset = 0;
@override
void initState() {
super.initState();
_meeduPlayerController = videoDetailController.meeduPlayerController;
_playerEventSubs = _meeduPlayerController!.onPlayerStatusChanged.listen(
(PlayerStatus status) {
if (status == PlayerStatus.playing) {
Wakelock.enable();
print('开始播放了');
isPlay = false;
isShowCover = false;
setState(() {});
} else {
isPlay = true;
setState(() {});
Wakelock.disable();
}
},
);
animationController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600));
_extendNestCtr.addListener(
() {
print(_extendNestCtr.position.pixels);
double offset = _extendNestCtr.position.pixels;
if (offset > doubleOffset) {
animationController.forward();
} else {
animationController.reverse();
}
doubleOffset = offset;
setState(() {});
},
);
}
Future<void> _meeduDispose() async {
if (_meeduPlayerController != null) {
_playerEventSubs?.cancel();
await _meeduPlayerController!.dispose();
_meeduPlayerController = null;
// The next line disables the wakelock again.
await Wakelock.disable();
}
}
@override
void dispose() {
videoDetailController.meeduPlayerController.dispose();
super.dispose();
}
@override
// 离开当前页面时
void didPushNext() async {
if(!_meeduPlayerController!.pipEnabled){
_meeduPlayerController!.pause();
}
super.didPushNext();
}
@override
// 返回当前页面时
void didPopNext() async {
if (_extendNestCtr.position.pixels == 0) {
await Future.delayed(const Duration(milliseconds: 300));
_meeduPlayerController!.play();
}
super.didPopNext();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
VideoDetailPage.routeObserver
.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
Widget build(BuildContext context) {
final double statusBarHeight = MediaQuery.of(context).padding.top;
final double pinnedHeaderHeight = statusBarHeight +
kToolbarHeight +
MediaQuery.of(context).size.width * 9 / 16;
final videoHeight = MediaQuery.of(context).size.width * 9 / 16;
final double pinnedHeaderHeight =
statusBarHeight + kToolbarHeight + videoHeight;
return DefaultTabController(
initialIndex: videoDetailController.tabInitialIndex,
length: videoDetailController.tabs.length, // tab的数量.
@@ -43,20 +139,19 @@ class _VideoDetailPageState extends State<VideoDetailPage>
resizeToAvoidBottomInset: false,
key: videoDetailController.scaffoldKey,
body: ExtendedNestedScrollView(
controller: _extendNestCtr,
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
title: const Text("视频详情"),
pinned: true,
automaticallyImplyLeading: false,
pinned: false,
elevation: 0,
scrolledUnderElevation: 0,
forceElevated: innerBoxIsScrolled,
expandedHeight:
MediaQuery.of(context).size.width * 9 / 16,
collapsedHeight:
MediaQuery.of(context).size.width * 9 / 16,
backgroundColor: Colors.black,
expandedHeight: videoHeight,
// collapsedHeight: videoHeight,
backgroundColor: Theme.of(context).colorScheme.background,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.only(
@@ -65,16 +160,66 @@ class _VideoDetailPageState extends State<VideoDetailPage>
builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
double PR =
MediaQuery.of(context).devicePixelRatio;
return Hero(
tag: videoDetailController.heroTag,
child: NetworkImgLayer(
type: 'emote',
src: videoDetailController.videoItem['pic'],
width: maxWidth,
height: maxHeight,
),
// double PR =
// MediaQuery.of(context).devicePixelRatio;
return Stack(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: MeeduVideoPlayer(
controller: _meeduPlayerController!,
header: (BuildContext context,
MeeduPlayerController
_meeduPlayerController,
Responsive) {
return AppBar(
toolbarHeight: 40,
backgroundColor: Colors.transparent,
primary: false,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: Colors.white,
leading: IconButton(
onPressed: () {
Get.back();
},
icon: const Icon(
Icons.arrow_back_ios,
size: 19,
),
),
title: Text(
'视频详情',
style: TextStyle(
color: Colors.white,
fontSize: Theme.of(context)
.textTheme
.titleSmall!
.fontSize),
),
);
},
),
),
Visibility(
visible: isShowCover,
child: Positioned(
top: 0,
left: 0,
right: 0,
child: Hero(
tag: videoDetailController.heroTag,
child: NetworkImgLayer(
type: 'emote',
src: videoDetailController
.videoItem['pic'],
width: maxWidth,
height: maxHeight,
),
),
),
),
],
);
},
),
@@ -84,7 +229,9 @@ class _VideoDetailPageState extends State<VideoDetailPage>
];
},
pinnedHeaderSliverHeightBuilder: () {
return pinnedHeaderHeight;
return isPlay
? MediaQuery.of(context).padding.top + 50
: pinnedHeaderHeight;
},
onlyOneScrollInBody: true,
body: Column(
@@ -148,6 +295,51 @@ class _VideoDetailPageState extends State<VideoDetailPage>
),
),
),
// 播放完成/暂停播放
Positioned(
top: -MediaQuery.of(context).padding.top +
(doubleOffset / videoHeight) * 50,
left: 0,
right: 0,
child: Opacity(
opacity: doubleOffset / videoHeight,
child: Container(
height: 50 + MediaQuery.of(context).padding.top,
color: Theme.of(context).colorScheme.background,
padding:
EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: AppBar(
primary: false,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
title: TextButton(
onPressed: () {
_extendNestCtr.animateTo(0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.play_arrow_rounded),
Text('继续播放')
],
),
),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(
Icons.share,
size: 20,
)),
const SizedBox(width: 12)
],
),
),
),
),
],
),
),