feat: 观看历史分类、记录视频播放进度
This commit is contained in:
@ -150,4 +150,8 @@ class Api {
|
|||||||
|
|
||||||
// 分类搜索
|
// 分类搜索
|
||||||
static const String searchByType = '/x/web-interface/search/type';
|
static const String searchByType = '/x/web-interface/search/type';
|
||||||
|
|
||||||
|
// 记录视频播放进度
|
||||||
|
// https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/video/report.md
|
||||||
|
static const String heartBeat = '/x/click-interface/web/heartbeat';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -303,4 +303,21 @@ class VideoHttp {
|
|||||||
return {'status': true, 'data': []};
|
return {'status': true, 'data': []};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视频播放进度
|
||||||
|
static Future heartBeat({aid, progress, realtime}) async {
|
||||||
|
var res = await Request().post(Api.heartBeat, queryParameters: {
|
||||||
|
'aid': aid,
|
||||||
|
// 'bvid': '',
|
||||||
|
// 'cid': '',
|
||||||
|
// 'epid': '',
|
||||||
|
// 'sid': '',
|
||||||
|
// 'mid': '',
|
||||||
|
'played_time': progress,
|
||||||
|
// 'realtime': realtime,
|
||||||
|
// 'type': '',
|
||||||
|
// 'sub_type': '',
|
||||||
|
'csrf': await Request.getCsrf(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
lib/models/common/business_type.dart
Normal file
23
lib/models/common/business_type.dart
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
enum BusinessType {
|
||||||
|
// 普通视频
|
||||||
|
archive,
|
||||||
|
// 剧集(番剧 / 影视)
|
||||||
|
pgc,
|
||||||
|
// 直播
|
||||||
|
live,
|
||||||
|
// 文章
|
||||||
|
articleList,
|
||||||
|
// 文章
|
||||||
|
article,
|
||||||
|
hiddenDurationType,
|
||||||
|
showBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BusinessTypeExtension on BusinessType {
|
||||||
|
String get type =>
|
||||||
|
['archive', 'pgc', 'live', 'article-list', 'article'][index];
|
||||||
|
// 隐藏时长
|
||||||
|
List get hiddenDurationType => ['live', 'article-list', 'article'];
|
||||||
|
// 右上
|
||||||
|
List get showBadge => ['pgc', 'article-list', 'article'];
|
||||||
|
}
|
||||||
@ -10,7 +10,6 @@ class HistoryController extends GetxController {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
queryHistoryList();
|
|
||||||
super.onInit();
|
super.onInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:pilipala/common/constants.dart';
|
import 'package:pilipala/common/constants.dart';
|
||||||
import 'package:pilipala/common/widgets/network_img_layer.dart';
|
import 'package:pilipala/common/widgets/network_img_layer.dart';
|
||||||
|
import 'package:pilipala/models/common/business_type.dart';
|
||||||
import 'package:pilipala/utils/utils.dart';
|
import 'package:pilipala/utils/utils.dart';
|
||||||
|
|
||||||
class HistoryItem extends StatelessWidget {
|
class HistoryItem extends StatelessWidget {
|
||||||
@ -15,8 +16,22 @@ class HistoryItem extends StatelessWidget {
|
|||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
Get.toNamed('/video?aid=$aid&cid=${videoItem.history.cid}',
|
if (videoItem.history.business.contains('article')) {
|
||||||
arguments: {'heroTag': heroTag, 'pic': videoItem.cover});
|
String cid = videoItem.history.cid != 0
|
||||||
|
? videoItem.history.cid.toString()
|
||||||
|
: videoItem.history.oid.toString();
|
||||||
|
Get.toNamed(
|
||||||
|
'/webview',
|
||||||
|
parameters: {
|
||||||
|
'url': 'https://www.bilibili.com/read/cv$cid',
|
||||||
|
'type': 'note',
|
||||||
|
'pageTitle': videoItem.title
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.toNamed('/video?aid=$aid&cid=${videoItem.history.cid}',
|
||||||
|
arguments: {'heroTag': heroTag, 'pic': videoItem.cover});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -47,27 +62,82 @@ class HistoryItem extends StatelessWidget {
|
|||||||
child: NetworkImgLayer(
|
child: NetworkImgLayer(
|
||||||
// src: videoItem['pic'] +
|
// src: videoItem['pic'] +
|
||||||
// '@${(maxWidth * 2).toInt()}w',
|
// '@${(maxWidth * 2).toInt()}w',
|
||||||
src: videoItem.cover + '@.webp',
|
src: (videoItem.cover != ''
|
||||||
|
? videoItem.cover
|
||||||
|
: videoItem.covers.first) +
|
||||||
|
'@.webp',
|
||||||
width: maxWidth,
|
width: maxWidth,
|
||||||
height: maxHeight,
|
height: maxHeight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (!BusinessType
|
||||||
right: 4,
|
.hiddenDurationType.hiddenDurationType
|
||||||
bottom: 4,
|
.contains(videoItem.history.business))
|
||||||
child: Container(
|
Positioned(
|
||||||
padding: const EdgeInsets.symmetric(
|
right: 4,
|
||||||
vertical: 1, horizontal: 6),
|
bottom: 4,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(4),
|
padding: const EdgeInsets.symmetric(
|
||||||
color: Colors.black54.withOpacity(0.4)),
|
vertical: 1, horizontal: 6),
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
Utils.timeFormat(videoItem.duration!),
|
borderRadius:
|
||||||
style: const TextStyle(
|
BorderRadius.circular(4),
|
||||||
fontSize: 11, color: Colors.white),
|
color:
|
||||||
|
Colors.black54.withOpacity(0.4)),
|
||||||
|
child: Text(
|
||||||
|
videoItem.progress == -1
|
||||||
|
? '已看完'
|
||||||
|
: '${Utils.timeFormat(videoItem.progress!)}/${Utils.timeFormat(videoItem.duration!)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
// 右上角
|
||||||
|
if (BusinessType.showBadge.showBadge
|
||||||
|
.contains(videoItem.history.business))
|
||||||
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
top: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 1, horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(4),
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer),
|
||||||
|
child: Text(
|
||||||
|
videoItem.badge,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (videoItem.history.business ==
|
||||||
|
BusinessType.live.type)
|
||||||
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
top: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 1, horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(4),
|
||||||
|
color:
|
||||||
|
Colors.black54.withOpacity(0.4)),
|
||||||
|
child: Text(
|
||||||
|
videoItem.badge,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -33,7 +33,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: () => _searchController.submit(),
|
onPressed: () => _searchController.submit(),
|
||||||
icon: const Icon(CupertinoIcons.search, size: 22)),
|
icon: const Icon(CupertinoIcons.search, size: 22)),
|
||||||
)
|
),
|
||||||
|
const SizedBox(width: 10)
|
||||||
],
|
],
|
||||||
title: Obx(
|
title: Obx(
|
||||||
() => TextField(
|
() => TextField(
|
||||||
|
|||||||
@ -45,6 +45,8 @@ class VideoDetailController extends GetxController {
|
|||||||
enabledButtons: const EnabledButtons(pip: true),
|
enabledButtons: const EnabledButtons(pip: true),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Timer? timer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
@ -115,8 +117,6 @@ class VideoDetailController extends GetxController {
|
|||||||
// log('result: ${result.toString()}');
|
// log('result: ${result.toString()}');
|
||||||
if (result['status']) {
|
if (result['status']) {
|
||||||
PlayUrlModel data = result['data'];
|
PlayUrlModel data = result['data'];
|
||||||
print(data.dash);
|
|
||||||
|
|
||||||
// 指定质量的视频 -> 最高质量的视频
|
// 指定质量的视频 -> 最高质量的视频
|
||||||
String videoUrl = data.dash!.video!.first.baseUrl!;
|
String videoUrl = data.dash!.video!.first.baseUrl!;
|
||||||
String audioUrl = data.dash!.audio!.first.baseUrl!;
|
String audioUrl = data.dash!.audio!.first.baseUrl!;
|
||||||
@ -124,4 +124,24 @@ class VideoDetailController extends GetxController {
|
|||||||
defaultST: Duration(milliseconds: data.lastPlayTime!));
|
defaultST: Duration(milliseconds: data.lastPlayTime!));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void loopHeartBeat() {
|
||||||
|
timer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||||
|
markHeartBeat();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void markHeartBeat() async {
|
||||||
|
Duration progress = meeduPlayerController.position.value;
|
||||||
|
await VideoHttp.heartBeat(aid: aid, progress: progress.inSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
markHeartBeat();
|
||||||
|
if (timer!.isActive) {
|
||||||
|
timer!.cancel();
|
||||||
|
}
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
|
|||||||
final VideoDetailController videoDetailController =
|
final VideoDetailController videoDetailController =
|
||||||
Get.put(VideoDetailController(), tag: Get.arguments['heroTag']);
|
Get.put(VideoDetailController(), tag: Get.arguments['heroTag']);
|
||||||
MeeduPlayerController? _meeduPlayerController;
|
MeeduPlayerController? _meeduPlayerController;
|
||||||
ScrollController _extendNestCtr = ScrollController();
|
final ScrollController _extendNestCtr = ScrollController();
|
||||||
late AnimationController animationController;
|
late AnimationController animationController;
|
||||||
|
|
||||||
// final _meeduPlayerController = MeeduPlayerController(
|
// final _meeduPlayerController = MeeduPlayerController(
|
||||||
@ -46,13 +46,15 @@ class _VideoDetailPageState extends State<VideoDetailPage>
|
|||||||
_meeduPlayerController = videoDetailController.meeduPlayerController;
|
_meeduPlayerController = videoDetailController.meeduPlayerController;
|
||||||
_playerEventSubs = _meeduPlayerController!.onPlayerStatusChanged.listen(
|
_playerEventSubs = _meeduPlayerController!.onPlayerStatusChanged.listen(
|
||||||
(PlayerStatus status) {
|
(PlayerStatus status) {
|
||||||
|
videoDetailController.markHeartBeat();
|
||||||
if (status == PlayerStatus.playing) {
|
if (status == PlayerStatus.playing) {
|
||||||
Wakelock.enable();
|
Wakelock.enable();
|
||||||
print('开始播放了');
|
|
||||||
isPlay = false;
|
isPlay = false;
|
||||||
isShowCover = false;
|
isShowCover = false;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
videoDetailController.loopHeartBeat();
|
||||||
} else {
|
} else {
|
||||||
|
videoDetailController.timer!.cancel();
|
||||||
isPlay = true;
|
isPlay = true;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
Wakelock.disable();
|
Wakelock.disable();
|
||||||
@ -92,6 +94,7 @@ class _VideoDetailPageState extends State<VideoDetailPage>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
videoDetailController.meeduPlayerController.dispose();
|
videoDetailController.meeduPlayerController.dispose();
|
||||||
|
videoDetailController.timer!.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +104,9 @@ class _VideoDetailPageState extends State<VideoDetailPage>
|
|||||||
if (!_meeduPlayerController!.pipEnabled) {
|
if (!_meeduPlayerController!.pipEnabled) {
|
||||||
_meeduPlayerController!.pause();
|
_meeduPlayerController!.pause();
|
||||||
}
|
}
|
||||||
|
if (videoDetailController.timer!.isActive) {
|
||||||
|
videoDetailController.timer!.cancel();
|
||||||
|
}
|
||||||
super.didPushNext();
|
super.didPushNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +117,9 @@ class _VideoDetailPageState extends State<VideoDetailPage>
|
|||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
_meeduPlayerController!.play();
|
_meeduPlayerController!.play();
|
||||||
}
|
}
|
||||||
|
if (!videoDetailController.timer!.isActive) {
|
||||||
|
videoDetailController.loopHeartBeat();
|
||||||
|
}
|
||||||
super.didPopNext();
|
super.didPopNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class Utils {
|
|||||||
int minute = time ~/ 60;
|
int minute = time ~/ 60;
|
||||||
double res = time / 60;
|
double res = time / 60;
|
||||||
if (minute != res) {
|
if (minute != res) {
|
||||||
return '${minute < 10 ? '0$minute' : minute} :${(time - minute * 60) < 10 ? '0${(time - minute * 60)}' : (time - minute * 60)}';
|
return '${minute < 10 ? '0$minute' : minute}:${(time - minute * 60) < 10 ? '0${(time - minute * 60)}' : (time - minute * 60)}';
|
||||||
} else {
|
} else {
|
||||||
return '$minute:00';
|
return '$minute:00';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user