refa: dir

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-05-03 13:57:47 +08:00
parent 57fa8b4f3e
commit 7f70ee5045
260 changed files with 748 additions and 967 deletions

View File

@@ -0,0 +1,629 @@
import 'dart:convert';
import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/user.dart';
import 'package:PiliPlus/pages/common/common_data_controller.dart';
import 'package:PiliPlus/pages/dynamics_repost/view.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/pages/video/pay_coins/view.dart';
import 'package:PiliPlus/pages/video/reply/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/global_data.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/http/constants.dart';
import 'package:PiliPlus/http/search.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/bangumi/info.dart';
import 'package:PiliPlus/models/user/fav_folder.dart';
import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart';
import 'package:PiliPlus/utils/feed_back.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart' as dom;
class BangumiIntroController
extends CommonDataController<BangumiInfoModel, BangumiInfoModel> {
// 视频bvid
String bvid = Get.parameters['bvid'] ?? '';
var seasonId = Get.parameters['seasonId'] != null
? int.tryParse(Get.parameters['seasonId']!)
: null;
var epId = Get.parameters['epId'] != null
? int.tryParse(Get.parameters['epId']!)
: null;
late dynamic type =
Get.parameters['type'] == '1' || Get.parameters['type'] == '4'
? '追番'
: '追剧';
// 是否预渲染 骨架屏
bool preRender = false;
// 视频详情 上个页面传入
Map? videoItem = {};
BangumiInfoModel? bangumiItem;
// up主粉丝数
Map userStat = {'follower': '-'};
// 是否点赞
RxBool hasLike = false.obs;
// 投币数量
final RxInt _coinNum = 0.obs;
// 是否投币
bool get hasCoin => _coinNum.value != 0;
// 是否收藏
RxBool hasFav = false.obs;
dynamic videoTags;
bool isLogin = false;
Rx<FavFolderData> favFolderData = FavFolderData().obs;
List? favIds;
dynamic userInfo;
late final enableQuickFav =
GStorage.setting.get(SettingBoxKey.enableQuickFav, defaultValue: false);
@override
void onInit() {
super.onInit();
if (Get.arguments.isNotEmpty as bool) {
if (Get.arguments.containsKey('bangumiItem') as bool) {
preRender = true;
bangumiItem = Get.arguments['bangumiItem'];
}
}
userInfo = GStorage.userInfo.get('userInfoCache');
isLogin = userInfo != null;
if (isLogin && epId != null) {
queryBangumiLikeCoinFav();
}
queryData();
if (isLogin && seasonId != null) {
queryIsFollowed();
}
}
@override
Future queryData([bool isRefresh = true]) async {
await queryVideoTags();
return super.queryData(isRefresh);
}
Future queryVideoTags() async {
var result = await UserHttp.videoTags(bvid: bvid);
if (result['status']) {
videoTags = result['data'];
}
}
@override
bool customHandleResponse(
bool isRefresh, Success<BangumiInfoModel> response) {
epId ??= response.response.episodes?.firstOrNull?.id;
loadingState.value = response;
return true;
}
@override
Future<LoadingState<BangumiInfoModel>> customGetData() =>
SearchHttp.bangumiInfoNew(seasonId: seasonId, epId: epId);
// 获取点赞/投币/收藏状态
Future queryBangumiLikeCoinFav() async {
var result = await VideoHttp.bangumiLikeCoinFav(epId: epId);
if (result['status']) {
hasLike.value = result["data"]['like'] == 1;
_coinNum.value = result["data"]['coin_number'];
hasFav.value = result["data"]['favorite'] == 1;
} else {
SmartDialog.showToast(result['msg']);
}
}
// (取消)点赞
Future actionLikeVideo() async {
var result = await VideoHttp.likeVideo(bvid: bvid, type: !hasLike.value);
if (result['status']) {
SmartDialog.showToast(!hasLike.value ? result['data']['toast'] : '取消赞');
BangumiInfoModel bangumiDetail = (loadingState.value as Success).response;
bangumiDetail.stat!['likes'] =
bangumiDetail.stat!['likes'] + (!hasLike.value ? 1 : -1);
hasLike.value = !hasLike.value;
} else {
SmartDialog.showToast(result['msg']);
}
}
void coinVideo(int coin, [bool selectLike = false]) async {
var res = await VideoHttp.coinVideo(
bvid: bvid,
multiply: coin,
selectLike: selectLike ? 1 : 0,
);
if (res['status']) {
SmartDialog.showToast('投币成功');
BangumiInfoModel bangumiDetail = (loadingState.value as Success).response;
bangumiDetail.stat!['coins'] = bangumiDetail.stat!['coins'] + coin;
if (selectLike && hasLike.value.not) {
hasLike.value = true;
bangumiDetail.stat!['likes'] = bangumiDetail.stat!['likes'] + 1;
}
_coinNum.value += coin;
GlobalData().afterCoin(coin);
} else {
SmartDialog.showToast(res['msg']);
}
}
// 投币
Future actionCoinVideo() async {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
if (_coinNum.value >= 2) {
SmartDialog.showToast('达到投币上限啦~');
return;
}
if (GlobalData().coins != null && GlobalData().coins! < 1) {
SmartDialog.showToast('硬币不足');
return;
}
PayCoinsPage.toPayCoinsPage(
onPayCoin: coinVideo,
hasCoin: _coinNum.value == 1,
);
}
// (取消)收藏 bangumi
Future actionFavVideo({type = 'choose'}) async {
// 收藏至默认文件夹
if (type == 'default') {
SmartDialog.showLoading(msg: '请求中');
queryVideoInFolder().then((res) async {
if (res['status']) {
int defaultFolderId = favFolderData.value.list!.first.id!;
int favStatus = favFolderData.value.list!.first.favState!;
var result = await VideoHttp.favVideo(
aid: epId,
type: 24,
addIds: favStatus == 0 ? '$defaultFolderId' : '',
delIds: favStatus == 1 ? '$defaultFolderId' : '',
);
SmartDialog.dismiss();
if (result['status']) {
// 重新获取收藏状态
await Future.delayed(const Duration(milliseconds: 255));
await queryBangumiLikeCoinFav();
SmartDialog.showToast('✅ 快速收藏/取消收藏成功');
} else {
SmartDialog.showToast(result['msg']);
}
} else {
SmartDialog.dismiss();
}
});
return;
}
List<int?> addMediaIdsNew = [];
List<int?> delMediaIdsNew = [];
try {
for (var i in favFolderData.value.list!.toList()) {
bool isFaved = favIds?.contains(i.id) == true;
if (i.favState == 1) {
if (isFaved.not) {
addMediaIdsNew.add(i.id);
}
} else {
if (isFaved) {
delMediaIdsNew.add(i.id);
}
}
}
} catch (_) {}
var result = await VideoHttp.favVideo(
aid: epId,
type: 24,
addIds: addMediaIdsNew.join(','),
delIds: delMediaIdsNew.join(','),
);
if (result['status']) {
SmartDialog.showToast('操作成功');
Get.back();
Future.delayed(const Duration(milliseconds: 255), () {
queryBangumiLikeCoinFav();
});
} else {
SmartDialog.showToast(result['msg']);
}
}
// 分享视频
Future actionShareVideo(context) async {
showDialog(
context: context,
builder: (_) {
String videoUrl = '${HttpString.baseUrl}/video/$bvid';
return AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
dense: true,
title: const Text(
'复制链接',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
Utils.copyText(videoUrl);
},
),
ListTile(
dense: true,
title: const Text(
'其它app打开',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
PageUtils.launchURL(videoUrl);
},
),
ListTile(
dense: true,
title: const Text(
'分享视频',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
Utils.shareText(videoUrl);
},
),
ListTile(
dense: true,
title: const Text(
'分享至动态',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
EpisodeItem? item = bangumiItem?.episodes
?.firstWhereOrNull((item) => item.epId == epId);
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => RepostPanel(
rid: epId,
/**
* 1番剧 // 4097
2电影 // 4098
3纪录片 // 4101
4国创 // 4100
5电视剧 // 4099
6漫画
7综艺 // 4099
*/
dynType: switch (Get.parameters['type']) {
'1' => 4097,
'2' => 4098,
'3' => 4101,
'4' => 4100,
'5' || '7' => 4099,
_ => -1,
},
pic: bangumiItem?.cover,
title:
'${bangumiItem?.title}${item != null ? '\n${item.showTitle}' : ''}',
uname: '',
),
);
},
),
ListTile(
dense: true,
title: const Text(
'分享至消息',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
try {
EpisodeItem item = bangumiItem!.episodes!
.firstWhere((item) => item.epId == epId);
final title = '${bangumiItem!.title!} ${item.showTitle}';
PageUtils.pmShare(
context,
content: {
"id": epId!.toString(),
"title": title,
"url": item.shareUrl,
"headline": title,
"source": 16,
"thumb": item.cover,
"source_desc": switch (bangumiItem!.type) {
1 => '番剧',
2 => '电影',
3 => '纪录片',
4 => '国创',
5 => '电视剧',
6 => '漫画',
7 => '综艺',
_ => null,
}
},
);
} catch (e) {
SmartDialog.showToast(e.toString());
}
},
),
],
),
);
});
}
// 选择文件夹
onChoose(bool checkValue, int index) {
feedBack();
List<FavFolderItemData> datalist = favFolderData.value.list!;
datalist[index].favState = checkValue ? 1 : 0;
datalist[index].mediaCount = checkValue
? datalist[index].mediaCount! + 1
: datalist[index].mediaCount! - 1;
favFolderData.value.list = datalist;
favFolderData.refresh();
}
// 修改分P或番剧分集
Future changeSeasonOrbangu(epId, bvid, cid, aid, cover) async {
// 重新获取视频资源
this.epId = epId;
this.bvid = bvid;
final videoDetailCtr =
Get.find<VideoDetailController>(tag: Get.arguments['heroTag'])
..plPlayerController.pause()
..makeHeartBeat()
..onReset()
..epId = epId
..bvid = bvid
..cid.value = cid
..danmakuCid.value = cid
..queryVideoUrl();
if (cover is String && cover.isNotEmpty) {
videoDetailCtr.videoItem['pic'] = cover;
}
// 重新请求评论
if (videoDetailCtr.showReply) {
try {
Get.find<VideoReplyController>(tag: Get.arguments['heroTag'])
..aid = aid
..onReload();
} catch (_) {}
}
if (isLogin) {
queryBangumiLikeCoinFav();
}
try {
Get.find<VideoIntroController>(tag: Get.arguments['heroTag'])
..bvid = bvid
..lastPlayCid.value = cid
..queryVideoIntro()
..queryOnlineTotal();
} catch (_) {}
}
// 追番
Future bangumiAdd() async {
var result = await VideoHttp.bangumiAdd(
seasonId: (loadingState.value as Success).response.seasonId);
if (result['status']) {
isFollowed.value = true;
followStatus.value = 2;
}
SmartDialog.showToast(result['msg']);
}
// 取消追番
Future bangumiDel() async {
var result = await VideoHttp.bangumiDel(
seasonId: (loadingState.value as Success).response.seasonId);
if (result['status']) {
isFollowed.value = false;
}
SmartDialog.showToast(result['msg']);
}
Future bangumiUpdate(status) async {
var result = await VideoHttp.bangumiUpdate(
seasonId: [(loadingState.value as Success).response.seasonId],
status: status,
);
if (result['status']) {
followStatus.value = status;
}
SmartDialog.showToast(result['msg']);
}
Future queryVideoInFolder() async {
favIds = null;
var result = await VideoHttp.videoInFolder(
mid: userInfo.mid,
rid: epId, // bangumi
type: 24, // bangumi
);
if (result['status']) {
favFolderData.value = result['data'];
favIds = favFolderData.value.list
?.where((item) => item.favState == 1)
.map((item) => item.id)
.toList();
}
return result;
}
bool prevPlay() {
late List episodes;
if ((loadingState.value as Success).response.episodes != null) {
episodes = (loadingState.value as Success).response.episodes!;
}
VideoDetailController videoDetailCtr =
Get.find<VideoDetailController>(tag: Get.arguments['heroTag']);
int currentIndex =
episodes.indexWhere((e) => e.cid == videoDetailCtr.cid.value);
int prevIndex = currentIndex - 1;
PlayRepeat playRepeat = videoDetailCtr.plPlayerController.playRepeat;
if (prevIndex < 0) {
if (playRepeat == PlayRepeat.listCycle) {
prevIndex = episodes.length - 1;
} else {
return false;
}
}
int epid = episodes[prevIndex].epId;
int cid = episodes[prevIndex].cid;
String bvid = episodes[prevIndex].bvid;
int aid = episodes[prevIndex].aid;
dynamic cover = episodes[prevIndex].cover;
changeSeasonOrbangu(epid, bvid, cid, aid, cover);
return true;
}
/// 列表循环或者顺序播放时,自动播放下一个;自动连播时,播放相关视频
bool nextPlay() {
try {
late List episodes;
VideoDetailController videoDetailCtr =
Get.find<VideoDetailController>(tag: Get.arguments['heroTag']);
PlayRepeat playRepeat = videoDetailCtr.plPlayerController.playRepeat;
if ((loadingState.value as Success<BangumiInfoModel>).response.episodes !=
null) {
episodes = (loadingState.value as Success<BangumiInfoModel>)
.response
.episodes!;
} else {
if (playRepeat == PlayRepeat.autoPlayRelated) {
return playRelated();
}
}
int currentIndex =
episodes.indexWhere((e) => e.cid == videoDetailCtr.cid.value);
int nextIndex = currentIndex + 1;
// 列表循环
if (nextIndex >= episodes.length) {
if (playRepeat == PlayRepeat.listCycle) {
nextIndex = 0;
} else if (playRepeat == PlayRepeat.autoPlayRelated) {
return playRelated();
} else {
return false;
}
}
int epid = episodes[nextIndex].epId;
int cid = episodes[nextIndex].cid;
String bvid = episodes[nextIndex].bvid;
int aid = episodes[nextIndex].aid;
dynamic cover = episodes[nextIndex].cover;
changeSeasonOrbangu(epid, bvid, cid, aid, cover);
return true;
} catch (_) {
return false;
}
}
bool playRelated() {
SmartDialog.showToast('番剧暂无相关视频');
return false;
}
// 一键三连
Future actionOneThree() async {
feedBack();
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
if (hasLike.value && hasCoin && hasFav.value) {
// 已点赞、投币、收藏
SmartDialog.showToast('已三连');
return false;
}
var result = await VideoHttp.triple(epId: epId, seasonId: seasonId);
if (result['status']) {
hasLike.value = result["data"]["like"] == 1;
if (result["data"]["coin"] == 1) {
_coinNum.value = 2;
GlobalData().afterCoin(2);
}
hasFav.value = result["data"]["favorite"] == 1;
SmartDialog.showToast('三连成功');
} else {
SmartDialog.showToast(result['msg']);
}
}
RxBool isFollowed = false.obs;
RxInt followStatus = (-1).obs;
Future queryIsFollowed() async {
try {
dynamic result = await Request().get(
'https://www.bilibili.com/bangumi/play/ss$seasonId',
);
dom.Document document = html_parser.parse(result.data);
dom.Element? scriptElement =
document.querySelector('script#__NEXT_DATA__');
if (scriptElement != null) {
dynamic scriptContent = jsonDecode(scriptElement.text);
isFollowed.value =
scriptContent['props']['pageProps']['followState']['isFollowed'];
followStatus.value =
scriptContent['props']['pageProps']['followState']['followStatus'];
}
} catch (_) {}
}
// 收藏
showFavBottomSheet(BuildContext context, {type = 'tap'}) {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
// 快速收藏 &
// 点按 收藏至默认文件夹
// 长按选择文件夹
if (enableQuickFav) {
if (type == 'tap') {
actionFavVideo(type: 'default');
} else {
PageUtils.showFavBottomSheet(context: context, ctr: this);
}
} else if (type != 'longPress') {
PageUtils.showFavBottomSheet(context: context, ctr: this);
}
}
}

View File

@@ -0,0 +1,675 @@
import 'dart:async';
import 'package:PiliPlus/common/widgets/dialog.dart';
import 'package:PiliPlus/common/widgets/http_error.dart';
import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart'
show SourceModel;
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/stat/stat.dart';
import 'package:PiliPlus/models/bangumi/info.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/widgets/bangumi_panel.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_row_item.dart';
import 'package:PiliPlus/utils/feed_back.dart';
class BangumiIntroPanel extends StatefulWidget {
final int? cid;
final String heroTag;
final Function showEpisodes;
final Function showIntroDetail;
const BangumiIntroPanel({
super.key,
this.cid,
required this.heroTag,
required this.showEpisodes,
required this.showIntroDetail,
});
@override
State<BangumiIntroPanel> createState() => _BangumiIntroPanelState();
}
class _BangumiIntroPanelState extends State<BangumiIntroPanel>
with AutomaticKeepAliveClientMixin {
late BangumiIntroController bangumiIntroController;
late VideoDetailController videoDetailCtr;
late int cid;
StreamSubscription? _listener;
// 添加页面缓存
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
cid = widget.cid!;
bangumiIntroController =
Get.put(BangumiIntroController(), tag: widget.heroTag);
videoDetailCtr = Get.find<VideoDetailController>(tag: widget.heroTag);
_listener = videoDetailCtr.cid.listen((int p0) {
cid = p0;
if (!mounted) return;
setState(() {});
});
}
@override
void dispose() {
_listener?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Obx(() => _buildBody(bangumiIntroController.loadingState.value));
}
_buildBody(LoadingState loadingState) {
return switch (loadingState) {
Loading() => BangumiInfo(
heroTag: widget.heroTag,
isLoading: true,
bangumiDetail: null,
cid: cid,
showEpisodes: widget.showEpisodes,
showIntroDetail: () {},
),
Success() => BangumiInfo(
heroTag: widget.heroTag,
isLoading: false,
bangumiDetail: loadingState.response,
cid: cid,
showEpisodes: widget.showEpisodes,
showIntroDetail: () => widget.showIntroDetail(
loadingState.response,
bangumiIntroController.videoTags,
),
),
Error() => HttpError(
errMsg: loadingState.errMsg,
onReload: bangumiIntroController.onReload,
),
};
}
}
class BangumiInfo extends StatefulWidget {
const BangumiInfo({
super.key,
this.isLoading = false,
this.bangumiDetail,
this.cid,
required this.showEpisodes,
required this.showIntroDetail,
required this.heroTag,
});
final bool isLoading;
final BangumiInfoModel? bangumiDetail;
final int? cid;
final Function showEpisodes;
final Function showIntroDetail;
final String heroTag;
@override
State<BangumiInfo> createState() => _BangumiInfoState();
}
class _BangumiInfoState extends State<BangumiInfo> {
late final BangumiIntroController bangumiIntroController;
late final VideoDetailController videoDetailCtr;
late final BangumiInfoModel? bangumiItem;
int? cid;
bool isProcessing = false;
void handleState(Future Function() action) async {
if (isProcessing.not) {
isProcessing = true;
await action();
isProcessing = false;
}
}
late final _coinKey = GlobalKey<ActionItemState>();
late final _favKey = GlobalKey<ActionItemState>();
StreamSubscription? _listener;
@override
void initState() {
super.initState();
bangumiIntroController =
Get.put(BangumiIntroController(), tag: widget.heroTag);
videoDetailCtr = Get.find<VideoDetailController>(tag: widget.heroTag);
bangumiItem = bangumiIntroController.bangumiItem;
cid = widget.cid!;
debugPrint('cid: $cid');
_listener = videoDetailCtr.cid.listen((p0) {
cid = p0;
if (!mounted) return;
setState(() {});
});
}
@override
void dispose() {
_listener?.cancel();
super.dispose();
}
// 视频介绍
showIntroDetail() {
feedBack();
widget.showIntroDetail();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
bool isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
return SliverPadding(
padding: EdgeInsets.only(
left: StyleString.safeSpace,
right: StyleString.safeSpace,
top: StyleString.safeSpace,
bottom: StyleString.safeSpace + MediaQuery.paddingOf(context).bottom,
),
sliver: SliverToBoxAdapter(
child: !widget.isLoading || bangumiItem != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () {
videoDetailCtr.onViewImage();
context.imageView(
imgList: [
SourceModel(
url: !widget.isLoading
? widget.bangumiDetail!.cover!
: bangumiItem!.cover!,
)
],
onDismissed: videoDetailCtr.onDismissed,
);
},
child: Hero(
tag: !widget.isLoading
? widget.bangumiDetail!.cover!
: bangumiItem!.cover!,
child: NetworkImgLayer(
width: isLandscape ? 115 / 0.75 : 115,
height: isLandscape ? 115 : 115 / 0.75,
src: !widget.isLoading
? widget.bangumiDetail!.cover!
: bangumiItem!.cover!,
semanticsLabel: '封面',
),
),
),
if (bangumiItem != null &&
bangumiItem!.rating != null)
PBadge(
text:
'评分 ${!widget.isLoading ? widget.bangumiDetail!.rating!['score']! : bangumiItem!.rating!['score']!}',
top: null,
right: 6,
bottom: 6,
left: null,
),
],
),
const SizedBox(width: 10),
Expanded(
child: GestureDetector(
onTap: showIntroDetail,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: isLandscape ? 115 : 115 / 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
!widget.isLoading
? widget.bangumiDetail!.title!
: bangumiItem!.title!,
style: const TextStyle(
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 20),
Obx(
() => FilledButton.tonal(
style: FilledButton.styleFrom(
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
visualDensity: const VisualDensity(
horizontal: -2,
vertical: -2,
),
foregroundColor:
bangumiIntroController
.isFollowed.value
? theme.colorScheme.outline
: null,
backgroundColor:
bangumiIntroController
.isFollowed.value
? theme.colorScheme
.onInverseSurface
: null,
),
onPressed: bangumiIntroController
.followStatus.value ==
-1
? null
: () {
if (bangumiIntroController
.isFollowed.value) {
showPgcFollowDialog(
context: context,
type: bangumiIntroController
.type,
followStatus:
bangumiIntroController
.followStatus.value,
onUpdateStatus:
(followStatus) {
if (followStatus == -1) {
bangumiIntroController
.bangumiDel();
} else {
bangumiIntroController
.bangumiUpdate(
followStatus);
}
},
);
} else {
bangumiIntroController
.bangumiAdd();
}
},
child: Text(
bangumiIntroController
.isFollowed.value
? '${bangumiIntroController.type}'
: '${bangumiIntroController.type}',
),
),
),
],
),
Row(
children: [
StatView(
context: context,
theme: 'gray',
value: Utils.numFormat(!widget.isLoading
? widget.bangumiDetail!.stat!['views']
: bangumiItem!.stat!['views']),
),
const SizedBox(width: 6),
StatDanMu(
context: context,
theme: 'gray',
value: Utils.numFormat(!widget.isLoading
? widget
.bangumiDetail!.stat!['danmakus']
: bangumiItem!.stat!['danmakus']),
),
if (isLandscape) ...[
const SizedBox(width: 6),
AreasAndPubTime(
widget: widget,
bangumiItem: bangumiItem,
theme: theme,
),
const SizedBox(width: 6),
NewEpDesc(
widget: widget,
bangumiItem: bangumiItem,
theme: theme,
),
]
],
),
SizedBox(height: isLandscape ? 2 : 6),
if (!isLandscape)
AreasAndPubTime(
widget: widget,
bangumiItem: bangumiItem,
theme: theme,
),
if (!isLandscape)
NewEpDesc(
widget: widget,
bangumiItem: bangumiItem,
theme: theme,
),
const Spacer(),
Text(
'简介:${!widget.isLoading ? widget.bangumiDetail!.evaluate! : bangumiItem!.evaluate!}',
maxLines: isLandscape ? 2 : 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.outline,
),
),
],
),
),
),
),
],
),
const SizedBox(height: 6),
// 点赞收藏转发 布局样式2
actionGrid(theme, bangumiIntroController),
// 番剧分p
if ((!widget.isLoading &&
widget.bangumiDetail!.episodes!.isNotEmpty) ||
bangumiItem != null &&
bangumiItem!.episodes!.isNotEmpty) ...[
BangumiPanel(
heroTag: widget.heroTag,
pages: bangumiItem != null
? bangumiItem!.episodes!
: widget.bangumiDetail!.episodes!,
cid: cid ??
(bangumiItem != null
? bangumiItem!.episodes!.first.cid
: widget.bangumiDetail!.episodes!.first.cid),
changeFuc: bangumiIntroController.changeSeasonOrbangu,
showEpisodes: widget.showEpisodes,
newEp: bangumiItem?.newEp,
)
],
],
)
: const SizedBox(
height: 100,
child: Center(
child: CircularProgressIndicator(),
),
),
),
);
}
Widget actionGrid(
ThemeData theme, BangumiIntroController bangumiIntroController) {
return Material(
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: SizedBox(
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
Obx(
() => ActionItem(
icon: const Icon(FontAwesomeIcons.thumbsUp),
selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp),
onTap: () =>
handleState(bangumiIntroController.actionLikeVideo),
onLongPress: bangumiIntroController.actionOneThree,
selectStatus: bangumiIntroController.hasLike.value,
loadingStatus: false,
semanticsLabel: '点赞',
text: !widget.isLoading
? Utils.numFormat(widget.bangumiDetail!.stat!['likes']!)
: Utils.numFormat(
bangumiItem!.stat!['likes']!,
),
needAnim: true,
hasTriple: bangumiIntroController.hasLike.value &&
bangumiIntroController.hasCoin &&
bangumiIntroController.hasFav.value,
callBack: (start) {
if (start) {
HapticFeedback.lightImpact();
_coinKey.currentState?.controller?.forward();
_favKey.currentState?.controller?.forward();
} else {
_coinKey.currentState?.controller?.reverse();
_favKey.currentState?.controller?.reverse();
}
},
),
),
Obx(
() => ActionItem(
key: _coinKey,
icon: const Icon(FontAwesomeIcons.b),
selectIcon: const Icon(FontAwesomeIcons.b),
onTap: () =>
handleState(bangumiIntroController.actionCoinVideo),
selectStatus: bangumiIntroController.hasCoin,
loadingStatus: false,
semanticsLabel: '投币',
text: !widget.isLoading
? Utils.numFormat(widget.bangumiDetail!.stat!['coins']!)
: Utils.numFormat(
bangumiItem!.stat!['coins']!,
),
needAnim: true,
),
),
Obx(
() => ActionItem(
key: _favKey,
icon: const Icon(FontAwesomeIcons.star),
selectIcon: const Icon(FontAwesomeIcons.solidStar),
onTap: () =>
bangumiIntroController.showFavBottomSheet(context),
onLongPress: () => bangumiIntroController
.showFavBottomSheet(context, type: 'longPress'),
selectStatus: bangumiIntroController.hasFav.value,
loadingStatus: false,
semanticsLabel: '收藏',
text: !widget.isLoading
? Utils.numFormat(
widget.bangumiDetail!.stat!['favorite']!)
: Utils.numFormat(
bangumiItem!.stat!['favorite']!,
),
needAnim: true,
),
),
ActionItem(
icon: const Icon(FontAwesomeIcons.comment),
selectIcon: const Icon(FontAwesomeIcons.reply),
onTap: () => videoDetailCtr.tabCtr.animateTo(1),
selectStatus: false,
loadingStatus: false,
semanticsLabel: '评论',
text: !widget.isLoading
? Utils.numFormat(widget.bangumiDetail!.stat!['reply']!)
: Utils.numFormat(bangumiItem!.stat!['reply']!),
),
ActionItem(
icon: const Icon(FontAwesomeIcons.shareFromSquare),
onTap: () => bangumiIntroController.actionShareVideo(context),
selectStatus: false,
loadingStatus: false,
semanticsLabel: '转发',
text: !widget.isLoading
? Utils.numFormat(widget.bangumiDetail!.stat!['share']!)
: Utils.numFormat(bangumiItem!.stat!['share']!)),
],
),
),
),
);
}
Widget actionRow(
BuildContext context,
BangumiIntroController bangumiIntroController,
VideoDetailController videoDetailCtr,
) {
return Row(children: [
Obx(
() => ActionRowItem(
icon: const Icon(FontAwesomeIcons.thumbsUp),
onTap: () => handleState(bangumiIntroController.actionLikeVideo),
selectStatus: bangumiIntroController.hasLike.value,
loadingStatus: widget.isLoading,
text: !widget.isLoading
? widget.bangumiDetail!.stat!['likes']!.toString()
: '-',
),
),
const SizedBox(width: 8),
Obx(
() => ActionRowItem(
icon: const Icon(FontAwesomeIcons.b),
onTap: () => handleState(bangumiIntroController.actionCoinVideo),
selectStatus: bangumiIntroController.hasCoin,
loadingStatus: widget.isLoading,
text: !widget.isLoading
? widget.bangumiDetail!.stat!['coins']!.toString()
: '-',
),
),
const SizedBox(width: 8),
Obx(
() => ActionRowItem(
icon: const Icon(FontAwesomeIcons.heart),
onTap: () => bangumiIntroController.showFavBottomSheet(context),
onLongPress: () => bangumiIntroController.showFavBottomSheet(context,
type: 'longPress'),
selectStatus: bangumiIntroController.hasFav.value,
loadingStatus: widget.isLoading,
text: !widget.isLoading
? widget.bangumiDetail!.stat!['favorite']!.toString()
: '-',
),
),
const SizedBox(width: 8),
ActionRowItem(
icon: const Icon(FontAwesomeIcons.comment),
onTap: () {
videoDetailCtr.tabCtr.animateTo(1);
},
selectStatus: false,
loadingStatus: widget.isLoading,
text: !widget.isLoading
? widget.bangumiDetail!.stat!['reply']!.toString()
: '-',
),
const SizedBox(width: 8),
ActionRowItem(
icon: const Icon(FontAwesomeIcons.share),
onTap: () => bangumiIntroController.actionShareVideo(context),
selectStatus: false,
loadingStatus: widget.isLoading,
text: '转发'),
]);
}
}
class AreasAndPubTime extends StatelessWidget {
const AreasAndPubTime({
super.key,
required this.widget,
required this.bangumiItem,
required this.theme,
});
final BangumiInfo widget;
final BangumiInfoModel? bangumiItem;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
!widget.isLoading
? (widget.bangumiDetail!.areas!.isNotEmpty
? widget.bangumiDetail!.areas!.first['name']
: '')
: (bangumiItem!.areas!.isNotEmpty
? bangumiItem!.areas!.first['name']
: ''),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.outline,
),
),
const SizedBox(width: 6),
Text(
!widget.isLoading
? widget.bangumiDetail!.publish!['pub_time_show']
: bangumiItem!.publish!['pub_time_show'],
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.outline,
),
),
],
);
}
}
class NewEpDesc extends StatelessWidget {
const NewEpDesc({
super.key,
required this.widget,
required this.bangumiItem,
required this.theme,
});
final BangumiInfo widget;
final BangumiInfoModel? bangumiItem;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Text(
!widget.isLoading
? widget.bangumiDetail!.newEp!['desc']
: bangumiItem!.newEp!['desc'],
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.outline,
),
);
}
}

View File

@@ -0,0 +1,240 @@
import 'dart:async';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/models/bangumi/info.dart';
import 'package:PiliPlus/utils/storage.dart';
class BangumiPanel extends StatefulWidget {
const BangumiPanel({
super.key,
required this.pages,
this.cid,
required this.changeFuc,
required this.showEpisodes,
required this.heroTag,
this.newEp,
});
final List<EpisodeItem> pages;
final int? cid;
final Function changeFuc;
final Function showEpisodes;
final String heroTag;
final dynamic newEp;
@override
State<BangumiPanel> createState() => _BangumiPanelState();
}
class _BangumiPanelState extends State<BangumiPanel> {
late int currentIndex;
final ScrollController listViewScrollCtr = ScrollController();
dynamic userInfo;
// 默认未开通
int vipStatus = 0;
late int cid;
late final VideoDetailController videoDetailCtr;
StreamSubscription? _listener;
@override
void initState() {
super.initState();
cid = widget.cid!;
currentIndex = widget.pages.indexWhere((e) => e.cid == cid);
scrollToIndex();
userInfo = GStorage.userInfo.get('userInfoCache');
if (userInfo != null) {
vipStatus = userInfo.vipStatus;
}
videoDetailCtr = Get.find<VideoDetailController>(tag: widget.heroTag);
_listener = videoDetailCtr.cid.listen((int p0) {
cid = p0;
currentIndex = widget.pages.indexWhere((EpisodeItem e) => e.cid == cid);
if (!mounted) return;
setState(() {});
scrollToIndex();
});
}
@override
void dispose() {
_listener?.cancel();
listViewScrollCtr.dispose();
super.dispose();
}
void scrollToIndex() {
WidgetsBinding.instance.addPostFrameCallback((_) {
listViewScrollCtr.animateTo(
(currentIndex * 150.0).clamp(listViewScrollCtr.position.minScrollExtent,
listViewScrollCtr.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 5, bottom: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('合集 '),
Expanded(
child: Text(
' 正在播放:${widget.pages[currentIndex].longTitle}',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.outline,
),
),
),
const SizedBox(width: 10),
SizedBox(
height: 34,
child: TextButton(
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () => widget.showEpisodes(
null,
null,
widget.pages,
widget.pages[currentIndex].bvid,
widget.pages[currentIndex].aid,
cid,
),
child: Text(
widget.newEp?['desc']?.contains('连载') == true
? '连载中,更新至${Utils.isStringNumeric(widget.newEp['title']) ? '${widget.newEp?['title']}' : '${widget.newEp?['title']}'}'
: widget.newEp?['desc'],
style: const TextStyle(fontSize: 13),
),
),
),
],
),
),
SizedBox(
height: 60,
child: ListView.builder(
controller: listViewScrollCtr,
scrollDirection: Axis.horizontal,
itemCount: widget.pages.length,
itemExtent: 150,
itemBuilder: (BuildContext context, int index) {
final item = widget.pages[index];
return Container(
width: 150,
margin: EdgeInsets.only(
right: index == widget.pages.length - 1 ? 0 : 10,
),
child: Material(
color: theme.colorScheme.onInverseSurface,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () {
if (item.badge != null &&
item.badge == '会员' &&
vipStatus != 1) {
SmartDialog.showToast('需要大会员');
}
widget.changeFuc(
item.epId,
item.bvid,
item.cid,
item.aid,
item.cover,
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: [
if (index == currentIndex) ...<Widget>[
Image.asset(
'assets/images/live.png',
color: theme.colorScheme.primary,
height: 12,
semanticLabel: "正在播放:",
),
const SizedBox(width: 6)
],
Expanded(
child: Text(
item.title ?? '${index + 1}',
maxLines: (item.longTitle != null &&
item.longTitle != '')
? 1
: 2,
style: TextStyle(
fontSize: 13,
color: index == currentIndex
? theme.colorScheme.primary
: theme.colorScheme.onSurface),
)),
const SizedBox(width: 2),
if (item.badge != null) ...[
const Spacer(),
if (item.badge == '会员') ...[
Image.asset(
'assets/images/big-vip.png',
height: 16,
semanticLabel: "大会员",
),
],
if (item.badge != '会员') ...[
Text(
item.badge!,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.primary,
),
),
],
]
],
),
if (item.longTitle != null &&
item.longTitle != '') ...[
const SizedBox(height: 3),
Text(
item.longTitle!,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: index == currentIndex
? theme.colorScheme.primary
: theme.colorScheme.onSurface),
overflow: TextOverflow.ellipsis,
)
]
],
),
),
),
),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,156 @@
import 'package:PiliPlus/common/widgets/stat/stat.dart';
import 'package:PiliPlus/models/bangumi/info.dart';
import 'package:PiliPlus/pages/common/common_collapse_slide_page.dart';
import 'package:PiliPlus/pages/search/widgets/search_text.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class IntroDetail extends CommonCollapseSlidePage {
final BangumiInfoModel bangumiDetail;
final dynamic videoTags;
const IntroDetail({
super.key,
required this.bangumiDetail,
this.videoTags,
});
@override
State<IntroDetail> createState() => _IntroDetailState();
}
class _IntroDetailState extends CommonCollapseSlidePageState<IntroDetail> {
@override
Widget buildPage(ThemeData theme) {
return Material(
color: theme.colorScheme.surface,
child: Column(
children: [
GestureDetector(
onTap: Get.back,
behavior: HitTestBehavior.opaque,
child: Container(
height: 35,
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 2),
child: Container(
width: 32,
height: 3,
decoration: BoxDecoration(
color:
theme.colorScheme.onSecondaryContainer.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(3))),
),
),
),
Expanded(
child: enableSlide ? slideList(theme) : buildList(theme),
)
],
),
);
}
@override
Widget buildList(ThemeData theme) {
final TextStyle smallTitle = TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface,
);
return ListView(
controller: ScrollController(),
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(
left: 14,
right: 14,
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
children: [
SelectableText(
widget.bangumiDetail.title!,
style: const TextStyle(
fontSize: 16,
),
),
const SizedBox(height: 4),
Row(
children: [
StatView(
context: context,
theme: 'gray',
value: Utils.numFormat(widget.bangumiDetail.stat!['views']),
),
const SizedBox(width: 6),
StatDanMu(
context: context,
theme: 'gray',
value: Utils.numFormat(widget.bangumiDetail.stat!['danmakus']),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
widget.bangumiDetail.areas!.first['name'],
style: smallTitle,
),
const SizedBox(width: 6),
Text(
widget.bangumiDetail.publish!['pub_time_show'],
style: smallTitle,
),
const SizedBox(width: 6),
Text(
widget.bangumiDetail.newEp!['desc'],
style: smallTitle,
),
],
),
const SizedBox(height: 20),
Text(
'简介:',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
SelectableText(
widget.bangumiDetail.evaluate!,
style: smallTitle.copyWith(fontSize: 14),
),
const SizedBox(height: 20),
Text(
'声优:',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
SelectableText(
widget.bangumiDetail.actors!,
style: smallTitle.copyWith(fontSize: 14),
),
if (widget.videoTags is List && widget.videoTags.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: (widget.videoTags as List)
.map(
(item) => SearchText(
fontSize: 13,
text: item['tag_name'],
onTap: (_) => Get.toNamed(
'/searchResult',
parameters: {
'keyword': item['tag_name'],
},
),
onLongPress: (_) => Utils.copyText(item['tag_name']),
),
)
.toList(),
)
],
],
);
}
}

View File

@@ -0,0 +1,927 @@
import 'dart:async';
import 'package:PiliPlus/http/api.dart';
import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/member.dart';
import 'package:PiliPlus/http/search.dart';
import 'package:PiliPlus/models/model_hot_video_item.dart';
import 'package:PiliPlus/pages/dynamics_repost/view.dart';
import 'package:PiliPlus/pages/video/pay_coins/view.dart';
import 'package:PiliPlus/pages/video/related/controller.dart';
import 'package:PiliPlus/pages/video/reply/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/global_data.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/request_utils.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:expandable/expandable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/http/constants.dart';
import 'package:PiliPlus/http/user.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/user/fav_folder.dart';
import 'package:PiliPlus/models/video/ai.dart';
import 'package:PiliPlus/models/video_detail_res.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart';
import 'package:PiliPlus/utils/feed_back.dart';
import 'package:PiliPlus/utils/id_utils.dart';
import 'package:PiliPlus/utils/storage.dart';
class VideoIntroController extends GetxController {
// 视频bvid
late String bvid;
// 是否预渲染 骨架屏
bool preRender = false;
// 视频详情 上个页面传入
Map videoItem = {};
late final RxMap staffRelations = {}.obs;
// 请求状态
RxBool isLoading = false.obs;
// 视频详情 请求返回
Rx<VideoDetailData> videoDetail = VideoDetailData().obs;
// up主粉丝数
RxMap<String, dynamic> userStat = RxMap<String, dynamic>({'follower': '-'});
dynamic videoTags;
// 是否点赞
RxBool hasLike = false.obs;
// 是否点踩
RxBool hasDislike = false.obs;
// 投币数量
final RxInt _coinNum = 0.obs;
// 是否投币
bool get hasCoin => _coinNum.value != 0;
// 是否收藏
RxBool hasFav = false.obs;
// 是否稍后再看
RxBool hasLater = false.obs;
bool isLogin = false;
Rx<FavFolderData> favFolderData = FavFolderData().obs;
Set? favIds;
// 关注状态 默认未关注
RxMap followStatus = {}.obs;
RxInt lastPlayCid = 0.obs;
dynamic userInfo;
// 同时观看
bool isShowOnlineTotal = false;
RxString total = '1'.obs;
Timer? timer;
String heroTag = '';
late ModelResult modelResult;
RxMap<String, dynamic> queryVideoIntroData =
RxMap<String, dynamic>({"status": true});
ExpandableController? expandableCtr;
late final showArgueMsg = GStorage.showArgueMsg;
late final enableAi =
GStorage.setting.get(SettingBoxKey.enableAi, defaultValue: false);
late final enableQuickFav =
GStorage.setting.get(SettingBoxKey.enableQuickFav, defaultValue: false);
@override
void onInit() {
super.onInit();
userInfo = GStorage.userInfo.get('userInfoCache');
try {
if (heroTag.isEmpty) {
heroTag = Get.arguments['heroTag'];
}
bvid = Get.parameters['bvid']!;
} catch (_) {}
if (Get.arguments.isNotEmpty) {
if (Get.arguments.containsKey('videoItem')) {
preRender = true;
var args = Get.arguments['videoItem'];
var keys = Get.arguments.keys.toList();
try {
if (args.pic != null && args.pic != '') {
videoItem['pic'] = args.pic;
} else if (args.cover != null && args.cover != '') {
videoItem['pic'] = args.cover;
}
} catch (_) {}
if (args.title is String) {
videoItem['title'] = args.title;
} else {
String str = '';
for (Map map in args.title) {
str += map['text'];
}
videoItem['title'] = str;
}
videoItem['stat'] = keys.contains('stat') ? args.stat : null;
videoItem['pubdate'] = keys.contains('pubdate') ? args.pubdate : null;
videoItem['owner'] = keys.contains('owner') ? args.owner : null;
}
}
isLogin = userInfo != null;
lastPlayCid.value = int.parse(Get.parameters['cid']!);
isShowOnlineTotal = GStorage.setting
.get(SettingBoxKey.enableOnlineTotal, defaultValue: false);
startTimer();
queryVideoIntro();
}
// 获取视频简介&分p
Future queryVideoIntro() async {
queryVideoTags();
var result = await VideoHttp.videoIntro(bvid: bvid);
if (result['status']) {
VideoDetailData data = result['data'];
if (videoDetail.value.ugcSeason?.id == data.ugcSeason?.id) {
// keep reversed season
data.ugcSeason = videoDetail.value.ugcSeason;
}
if (videoDetail.value.cid == data.cid) {
// keep reversed pages
data.pages = videoDetail.value.pages;
data.isPageReversed = videoDetail.value.isPageReversed;
}
videoDetail.value = data;
videoItem['staff'] = data.staff;
try {
final videoDetailController =
Get.find<VideoDetailController>(tag: heroTag);
if (videoDetailController.videoItem['pic'] == null ||
videoDetailController.videoItem['pic'] == '' ||
(videoDetailController.videoUrl.isNullOrEmpty &&
videoDetailController.isQuerying.not)) {
videoDetailController.videoItem['pic'] = data.pic;
}
if (videoDetailController.showReply) {
try {
final videoReplyController =
Get.find<VideoReplyController>(tag: heroTag);
videoReplyController.count.value = data.stat?.reply ?? 0;
} catch (_) {}
}
} catch (_) {}
if (videoDetail.value.pages != null &&
videoDetail.value.pages!.isNotEmpty &&
lastPlayCid.value == 0) {
lastPlayCid.value = videoDetail.value.pages!.first.cid!;
}
queryUserStat();
} else {
SmartDialog.showToast(
"${result['code']} ${result['msg']} ${result['data']}");
}
queryVideoIntroData.addAll(result);
if (isLogin) {
queryAllStatus();
queryFollowStatus();
}
}
Future queryVideoTags() async {
var result = await UserHttp.videoTags(bvid: bvid);
if (result['status']) {
videoTags = result['data'];
}
}
// 获取up主粉丝数
Future queryUserStat() async {
if (videoItem['staff']?.isNotEmpty == true) {
Request().get(
Api.relations,
queryParameters: {
'fids': (videoItem['staff'] as List<Staff>)
.map((item) => item.mid)
.join(',')
},
).then((res) {
if (res.data['code'] == 0) {
staffRelations.addAll({
'status': true,
if (res.data['data'] != null) ...res.data['data'],
});
}
});
} else {
if (videoDetail.value.owner == null) {
return;
}
var result =
await MemberHttp.memberCardInfo(mid: videoDetail.value.owner!.mid!);
if (result['status']) {
userStat.addAll(result['data']);
}
}
}
Future<void> queryAllStatus() async {
var result = await VideoHttp.videoRelation(bvid: bvid);
if (result['status']) {
var data = result['data'];
hasLike.value = data['like'];
hasDislike.value = data['dislike'];
_coinNum.value = data['coin'];
hasFav.value = data['favorite'];
}
}
// 一键三连
Future actionOneThree() async {
feedBack();
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
if (hasLike.value && hasCoin && hasFav.value) {
// 已点赞、投币、收藏
SmartDialog.showToast('已三连');
return false;
}
var result = await VideoHttp.oneThree(bvid: bvid);
if (result['status']) {
hasLike.value = result["data"]["like"];
if (result["data"]["coin"]) {
_coinNum.value = 2;
GlobalData().afterCoin(2);
}
hasFav.value = result["data"]["fav"];
SmartDialog.showToast('三连成功');
} else {
SmartDialog.showToast(result['msg']);
}
}
// (取消)点赞
Future actionLikeVideo() async {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
if (videoDetail.value.stat?.like == null) {
return;
}
var result = await VideoHttp.likeVideo(bvid: bvid, type: !hasLike.value);
if (result['status']) {
if (!hasLike.value) {
SmartDialog.showToast(result['data']['toast']);
hasLike.value = true;
hasDislike.value = false;
videoDetail.value.stat!.like = videoDetail.value.stat!.like! + 1;
} else if (hasLike.value) {
SmartDialog.showToast('取消赞');
hasLike.value = false;
videoDetail.value.stat!.like = videoDetail.value.stat!.like! - 1;
}
} else {
SmartDialog.showToast(result['msg']);
}
}
Future actionDislikeVideo() async {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
var result =
await VideoHttp.dislikeVideo(bvid: bvid, type: !hasDislike.value);
if (result['status']) {
if (!hasDislike.value) {
SmartDialog.showToast('点踩成功');
hasDislike.value = true;
hasLike.value = false;
} else {
SmartDialog.showToast('取消踩');
hasDislike.value = false;
}
} else {
SmartDialog.showToast(result['msg']);
}
}
Future viewLater() async {
var res = await (hasLater.value
? UserHttp.toViewDel(aids: [IdUtils.bv2av(bvid)])
: await UserHttp.toViewLater(bvid: bvid));
if (res['status']) hasLater.value = !hasLater.value;
SmartDialog.showToast(res['msg']);
}
void coinVideo(int coin, [bool selectLike = false]) async {
if (videoDetail.value.stat?.coin == null) {
// not init
return;
}
var res = await VideoHttp.coinVideo(
bvid: bvid,
multiply: coin,
selectLike: selectLike ? 1 : 0,
);
if (res['status']) {
SmartDialog.showToast('投币成功');
_coinNum.value += coin;
GlobalData().afterCoin(coin);
videoDetail.value.stat!.coin = videoDetail.value.stat!.coin! + coin;
if (selectLike && hasLike.value.not) {
hasLike.value = true;
videoDetail.value.stat!.like = videoDetail.value.stat!.like! + 1;
}
} else {
SmartDialog.showToast(res['msg']);
}
}
// 投币
Future actionCoinVideo() async {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
int copyright =
(queryVideoIntroData['data'] as VideoDetailData?)?.copyright ?? 1;
if ((copyright != 1 && _coinNum.value >= 1) || _coinNum.value >= 2) {
SmartDialog.showToast('达到投币上限啦~');
return;
}
if (GlobalData().coins != null && GlobalData().coins! < 1) {
SmartDialog.showToast('硬币不足');
return;
}
PayCoinsPage.toPayCoinsPage(
onPayCoin: coinVideo,
copyright: copyright,
hasCoin: _coinNum.value == 1,
);
}
// (取消)收藏
Future actionFavVideo({type = 'choose'}) async {
// 收藏至默认文件夹
if (type == 'default') {
SmartDialog.showLoading(msg: '请求中');
queryVideoInFolder().then((res) async {
if (res['status']) {
int defaultFolderId = favFolderData.value.list!.first.id!;
bool notInDefFolder = favFolderData.value.list!.first.favState! == 0;
var result = await VideoHttp.favVideo(
aid: IdUtils.bv2av(bvid),
addIds: notInDefFolder ? '$defaultFolderId' : '',
delIds: !notInDefFolder ? '$defaultFolderId' : '',
);
SmartDialog.dismiss();
if (result['status']) {
hasFav.value = !hasFav.value || (hasFav.value && notInDefFolder);
// 重新获取收藏状态
// await queryHasFavVideo();
SmartDialog.showToast('✅ 快速收藏/取消收藏成功');
} else {
SmartDialog.showToast(result['msg']);
}
} else {
SmartDialog.dismiss();
}
});
return;
}
List<int?> addMediaIdsNew = [];
List<int?> delMediaIdsNew = [];
try {
for (var i in favFolderData.value.list!.toList()) {
bool isFaved = favIds?.contains(i.id) == true;
if (i.favState == 1) {
if (isFaved.not) {
addMediaIdsNew.add(i.id);
}
} else {
if (isFaved) {
delMediaIdsNew.add(i.id);
}
}
}
} catch (e) {
debugPrint(e.toString());
}
SmartDialog.showLoading(msg: '请求中');
var result = await VideoHttp.favVideo(
aid: IdUtils.bv2av(bvid),
addIds: addMediaIdsNew.join(','),
delIds: delMediaIdsNew.join(','),
);
SmartDialog.dismiss();
if (result['status']) {
Get.back();
hasFav.value =
addMediaIdsNew.isNotEmpty || favIds?.length != delMediaIdsNew.length;
SmartDialog.showToast('操作成功');
} else {
SmartDialog.showToast(result['msg']);
}
}
// 分享视频
Future actionShareVideo(context) async {
showDialog(
context: context,
builder: (_) {
String videoUrl = '${HttpString.baseUrl}/video/$bvid';
return AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
dense: true,
title: const Text(
'复制链接',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
Utils.copyText(videoUrl);
},
),
ListTile(
dense: true,
title: const Text(
'其它app打开',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
PageUtils.launchURL(videoUrl);
},
),
ListTile(
dense: true,
title: const Text(
'分享视频',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
Utils.shareText('${videoDetail.value.title} '
'UP主: ${videoDetail.value.owner!.name!}'
' - $videoUrl');
},
),
ListTile(
dense: true,
title: const Text(
'分享至动态',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => RepostPanel(
rid: videoDetail.value.aid,
dynType: 8,
pic: videoDetail.value.pic,
title: videoDetail.value.title,
uname: videoDetail.value.owner?.name,
),
);
},
),
ListTile(
dense: true,
title: const Text(
'分享至消息',
style: TextStyle(fontSize: 14),
),
onTap: () {
Get.back();
try {
PageUtils.pmShare(
context,
content: {
"id": videoDetail.value.aid!.toString(),
"title": videoDetail.value.title!,
"headline": videoDetail.value.title!,
"source": 5,
"thumb": videoDetail.value.pic!,
"author": videoDetail.value.owner!.name!,
"author_id": videoDetail.value.owner!.mid!.toString(),
},
);
} catch (e) {
SmartDialog.showToast(e.toString());
}
},
),
],
),
);
});
}
Future queryVideoInFolder() async {
favIds = null;
var result = await VideoHttp.videoInFolder(
mid: userInfo.mid, rid: IdUtils.bv2av(bvid));
if (result['status']) {
favFolderData.value = result['data'];
favIds = favFolderData.value.list
?.where((item) => item.favState == 1)
.map((item) => item.id)
.toSet();
}
return result;
}
// 选择文件夹
onChoose(bool checkValue, int index) {
feedBack();
List<FavFolderItemData> datalist = favFolderData.value.list!;
datalist[index].favState = checkValue ? 1 : 0;
datalist[index].mediaCount = checkValue
? datalist[index].mediaCount! + 1
: datalist[index].mediaCount! - 1;
favFolderData.value.list = datalist;
favFolderData.refresh();
}
// 查询关注状态
Future queryFollowStatus() async {
if (videoDetail.value.owner == null) {
return;
}
var result = await UserHttp.hasFollow(videoDetail.value.owner!.mid!);
if (result['status']) {
Map data = result['data'];
if (data['special'] == 1) data['attribute'] = -10;
followStatus.value = data;
}
}
// 关注/取关up
Future actionRelationMod(BuildContext context) async {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
int attr = followStatus['attribute'] ?? 0;
if (attr == 128) {
dynamic res = await VideoHttp.relationMod(
mid: videoDetail.value.owner?.mid ?? -1,
act: 6,
reSrc: 11,
);
if (res['status']) {
followStatus['attribute'] = 0;
}
return;
} else {
RequestUtils.actionRelationMod(
context: context,
mid: videoDetail.value.owner?.mid,
isFollow: attr != 0,
followStatus: followStatus,
callback: (attribute) {
followStatus['attribute'] = attribute;
},
);
}
}
// 修改分P或番剧分集
bool changeSeasonOrbangu(epid, bvid, cid, aid, cover, [isStein]) {
// 重新获取视频资源
final videoDetailCtr = Get.find<VideoDetailController>(tag: heroTag);
if (videoDetailCtr.isPlayAll) {
if (videoDetailCtr.mediaList.indexWhere((item) => item.bvid == bvid) ==
-1) {
PageUtils.toVideoPage(
'bvid=$bvid&cid=$cid',
arguments: {
if (cover != null) 'pic': cover,
'heroTag': Utils.makeHeroTag(bvid),
},
);
return false;
}
}
videoDetailCtr
..plPlayerController.pause()
..makeHeartBeat()
..updateMediaListHistory(aid)
..onReset(isStein)
..bvid = bvid
..oid.value = aid ?? IdUtils.bv2av(bvid)
..cid.value = cid
..danmakuCid.value = cid
..queryVideoUrl();
if (this.bvid != bvid) {
if (cover is String && cover.isNotEmpty) {
videoDetailCtr.videoItem['pic'] = cover;
}
// 重新请求相关视频
if (videoDetailCtr.showRelatedVideo) {
try {
Get.find<RelatedController>(tag: heroTag)
..bvid = bvid
..queryData();
} catch (_) {}
}
// 重新请求评论
if (videoDetailCtr.showReply) {
try {
Get.find<VideoReplyController>(tag: heroTag)
..aid = aid
..onReload();
} catch (_) {}
}
hasLater.value = false;
this.bvid = bvid;
queryVideoIntro();
}
lastPlayCid.value = cid;
queryOnlineTotal();
return true;
}
void startTimer() {
if (isShowOnlineTotal) {
queryOnlineTotal();
timer ??= Timer.periodic(const Duration(seconds: 10), (Timer timer) {
queryOnlineTotal();
});
}
}
void canelTimer() {
timer?.cancel();
timer = null;
}
// 查看同时在看人数
Future queryOnlineTotal() async {
if (isShowOnlineTotal.not) {
return;
}
dynamic result = await VideoHttp.onlineTotal(
aid: IdUtils.bv2av(bvid),
bvid: bvid,
cid: lastPlayCid.value,
);
if (result['status']) {
total.value = result['data'];
}
}
@override
void onClose() {
canelTimer();
expandableCtr?.dispose();
expandableCtr = null;
super.onClose();
}
/// 播放上一个
bool prevPlay([bool skipPages = false]) {
final List episodes = [];
bool isPages = false;
final videoDetailCtr = Get.find<VideoDetailController>(tag: heroTag);
if (skipPages.not && (videoDetail.value.pages?.length ?? 0) > 1) {
isPages = true;
final List<Part> pages = videoDetail.value.pages!;
episodes.addAll(pages);
} else if (videoDetailCtr.isPlayAll) {
episodes.addAll(videoDetailCtr.mediaList);
} else if (videoDetail.value.ugcSeason != null) {
final UgcSeason ugcSeason = videoDetail.value.ugcSeason!;
final List<SectionItem> sections = ugcSeason.sections!;
for (int i = 0; i < sections.length; i++) {
final List<EpisodeItem> episodesList = sections[i].episodes!;
episodes.addAll(episodesList);
}
}
final int currentIndex = episodes.indexWhere((e) =>
e.cid ==
(skipPages
? videoDetail.value.isPageReversed == true
? videoDetail.value.pages!.last.cid
: videoDetail.value.pages!.first.cid
: lastPlayCid.value));
int prevIndex = currentIndex - 1;
final PlayRepeat playRepeat = videoDetailCtr.plPlayerController.playRepeat;
// 列表循环
if (prevIndex < 0) {
if (isPages &&
(videoDetailCtr.isPlayAll || videoDetail.value.ugcSeason != null)) {
return prevPlay(true);
}
if (playRepeat == PlayRepeat.listCycle) {
prevIndex = episodes.length - 1;
} else {
return false;
}
}
final int cid = episodes[prevIndex].cid!;
final String rBvid = isPages ? bvid : episodes[prevIndex].bvid;
final int rAid = isPages ? IdUtils.bv2av(bvid) : episodes[prevIndex].aid!;
changeSeasonOrbangu(null, rBvid, cid, rAid, null);
return true;
}
/// 列表循环或者顺序播放时,自动播放下一个
bool nextPlay([bool skipPages = false]) {
try {
final List episodes = [];
bool isPages = false;
final videoDetailCtr = Get.find<VideoDetailController>(tag: heroTag);
// part -> playall -> season
if (skipPages.not && (videoDetail.value.pages?.length ?? 0) > 1) {
isPages = true;
final List<Part> pages = videoDetail.value.pages!;
episodes.addAll(pages);
} else if (videoDetailCtr.isPlayAll) {
episodes.addAll(videoDetailCtr.mediaList);
} else if (videoDetail.value.ugcSeason != null) {
final UgcSeason ugcSeason = videoDetail.value.ugcSeason!;
final List<SectionItem> sections = ugcSeason.sections!;
for (int i = 0; i < sections.length; i++) {
final List<EpisodeItem> episodesList = sections[i].episodes!;
episodes.addAll(episodesList);
}
}
final PlayRepeat playRepeat =
videoDetailCtr.plPlayerController.playRepeat;
if (episodes.isEmpty) {
if (playRepeat == PlayRepeat.autoPlayRelated &&
videoDetailCtr.showRelatedVideo) {
return playRelated();
}
return false;
}
final int currentIndex = episodes.indexWhere((e) =>
e.cid ==
(skipPages
? videoDetail.value.isPageReversed == true
? videoDetail.value.pages!.last.cid
: videoDetail.value.pages!.first.cid
: videoDetailCtr.cid.value));
int nextIndex = currentIndex + 1;
if (isPages.not &&
videoDetailCtr.isPlayAll &&
currentIndex == episodes.length - 2) {
videoDetailCtr.getMediaList();
}
// 列表循环
if (nextIndex >= episodes.length) {
if (isPages &&
(videoDetailCtr.isPlayAll || videoDetail.value.ugcSeason != null)) {
return nextPlay(true);
}
if (playRepeat == PlayRepeat.listCycle) {
nextIndex = 0;
} else if (playRepeat == PlayRepeat.autoPlayRelated &&
videoDetailCtr.showRelatedVideo) {
return playRelated();
} else {
return false;
}
}
int cid = episodes[nextIndex].cid!;
while (cid == -1) {
SmartDialog.showToast('当前视频暂不支持播放,自动跳过');
nextIndex++;
if (nextIndex >= episodes.length) {
return false;
}
cid = episodes[nextIndex].cid!;
}
final String rBvid = isPages ? bvid : episodes[nextIndex].bvid;
final int rAid = isPages ? IdUtils.bv2av(bvid) : episodes[nextIndex].aid!;
changeSeasonOrbangu(null, rBvid, cid, rAid, null);
return true;
} catch (_) {
return false;
}
}
bool playRelated() {
late RelatedController relatedCtr;
try {
relatedCtr = Get.find<RelatedController>(tag: heroTag);
if (relatedCtr.loadingState.value is! Success) {
return false;
}
if ((relatedCtr.loadingState.value as Success).response.isEmpty == true) {
SmartDialog.showToast('暂无相关视频,停止连播');
return false;
}
} catch (_) {
relatedCtr = Get.put(RelatedController(), tag: heroTag);
relatedCtr.queryData().then((value) {
if (value['status']) {
playRelated();
}
});
return false;
}
final HotVideoItemModel videoItem =
(relatedCtr.loadingState.value as Success).response[0];
try {
if (videoItem.cid != null) {
changeSeasonOrbangu(
null,
videoItem.bvid,
videoItem.cid,
videoItem.aid,
videoItem.pic,
);
} else {
SearchHttp.ab2c(aid: videoItem.aid, bvid: videoItem.bvid).then(
(cid) => PageUtils.toVideoPage(
'bvid=${videoItem.bvid}&cid=${videoItem.cid}',
arguments: {
'videoItem': videoItem,
'heroTag': heroTag,
},
off: true,
),
);
}
} catch (err) {
SmartDialog.showToast(err.toString());
}
return true;
}
// ai总结
Future aiConclusion() async {
SmartDialog.showLoading(msg: '正在获取AI总结');
final res = await VideoHttp.aiConclusion(
bvid: bvid,
cid: lastPlayCid.value,
upMid: videoDetail.value.owner?.mid,
);
SmartDialog.dismiss();
if (res['status']) {
modelResult = res['data'].modelResult;
} else {
SmartDialog.showToast("当前视频可能暂不支持AI视频总结");
}
return res;
}
// 收藏
showFavBottomSheet(BuildContext context, {type = 'tap'}) {
if (userInfo == null) {
SmartDialog.showToast('账号未登录');
return;
}
// 快速收藏 &
// 点按 收藏至默认文件夹
// 长按选择文件夹
if (enableQuickFav) {
if (type == 'tap') {
actionFavVideo(type: 'default');
} else {
PageUtils.showFavBottomSheet(context: context, ctr: this);
}
} else if (type != 'longPress') {
PageUtils.showFavBottomSheet(context: context, ctr: this);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
import 'dart:async';
import 'dart:math';
import 'package:PiliPlus/utils/extension.dart';
import 'package:flutter/material.dart';
import 'package:PiliPlus/utils/feed_back.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
class ActionItem extends StatefulWidget {
final Icon icon;
final Icon? selectIcon;
final Function? onTap;
final Function? onLongPress;
final bool? loadingStatus;
final String? text;
final bool selectStatus;
final String semanticsLabel;
final bool needAnim;
final bool hasTriple;
final Function? callBack;
final bool? expand;
const ActionItem({
super.key,
required this.icon,
this.selectIcon,
this.onTap,
this.onLongPress,
this.loadingStatus,
this.text,
this.selectStatus = false,
this.needAnim = false,
this.hasTriple = false,
this.callBack,
required this.semanticsLabel,
this.expand,
});
@override
State<ActionItem> createState() => ActionItemState();
}
class ActionItemState extends State<ActionItem>
with SingleTickerProviderStateMixin {
AnimationController? controller;
Animation<double>? _animation;
late final _isThumbsUp = widget.semanticsLabel == '点赞';
late int _lastTime;
late bool _hideCircle = false;
Timer? _timer;
void _startLongPress() {
_lastTime = DateTime.now().millisecondsSinceEpoch;
_timer ??= Timer(const Duration(milliseconds: 200), () {
if (widget.hasTriple) {
HapticFeedback.lightImpact();
SmartDialog.showToast('已经完成三连');
} else {
controller?.forward();
widget.callBack?.call(true);
}
cancelTimer();
});
}
void _cancelLongPress([bool isCancel = false]) {
int duration = DateTime.now().millisecondsSinceEpoch - _lastTime;
if (duration >= 200 && duration < 1500) {
if (widget.hasTriple.not) {
controller?.reverse();
widget.callBack?.call(false);
}
} else if (duration < 200) {
cancelTimer();
if (!isCancel) {
feedBack();
widget.onTap?.call();
}
}
}
@override
void initState() {
super.initState();
if (widget.needAnim) {
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
reverseDuration: const Duration(milliseconds: 400),
)..addListener(listener);
_animation = Tween<double>(begin: 0, end: -2 * pi).animate(
CurvedAnimation(
parent: controller!,
curve: Curves.easeInOut,
),
);
}
}
void listener() {
setState(() {
_hideCircle = controller?.value == 1;
if (_hideCircle) {
controller?.reset();
if (_isThumbsUp) {
widget.onLongPress?.call();
}
}
});
}
void cancelTimer() {
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
cancelTimer();
controller?.removeListener(listener);
controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return widget.expand == false
? _buildItem(theme)
: Expanded(child: _buildItem(theme));
}
Widget _buildItem(ThemeData theme) => Semantics(
label: (widget.text ?? "") +
(widget.selectStatus ? "" : "") +
widget.semanticsLabel,
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: _isThumbsUp
? null
: () {
feedBack();
widget.onTap?.call();
},
onLongPress: _isThumbsUp
? null
: () {
widget.onLongPress?.call();
},
onTapDown: (details) => _isThumbsUp ? _startLongPress() : null,
onTapUp: (details) => _isThumbsUp ? _cancelLongPress() : null,
onTapCancel: () => _isThumbsUp ? _cancelLongPress(true) : null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
if (widget.needAnim && !_hideCircle)
CustomPaint(
size: const Size(28, 28),
painter: _ArcPainter(
color: theme.colorScheme.primary,
sweepAngle: _animation!.value,
),
)
else
const SizedBox(width: 28, height: 28),
Icon(
widget.selectStatus
? widget.selectIcon!.icon!
: widget.icon.icon,
size: 18,
color: widget.selectStatus
? theme.colorScheme.primary
: widget.icon.color ?? theme.colorScheme.outline,
),
],
),
if (widget.text != null)
AnimatedOpacity(
opacity: widget.loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
widget.text!,
key: ValueKey<String>(widget.text!),
style: TextStyle(
color: widget.selectStatus
? theme.colorScheme.primary
: theme.colorScheme.outline,
fontSize: theme.textTheme.labelSmall!.fontSize,
),
semanticsLabel: "",
),
),
),
],
),
),
);
}
class _ArcPainter extends CustomPainter {
const _ArcPainter({
required this.color,
required this.sweepAngle,
});
final Color color;
final double sweepAngle;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final rect = Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2),
radius: size.width / 2,
);
const startAngle = -pi / 2;
canvas.drawArc(rect, startAngle, sweepAngle, false, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:PiliPlus/utils/feed_back.dart';
class ActionRowItem extends StatelessWidget {
final Icon? icon;
final Icon? selectIcon;
final Function? onTap;
final bool? loadingStatus;
final String? text;
final bool selectStatus;
final Function? onLongPress;
const ActionRowItem({
super.key,
this.icon,
this.selectIcon,
this.onTap,
this.loadingStatus,
this.text,
this.selectStatus = false,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: selectStatus
? theme.colorScheme.primaryContainer.withOpacity(0.6)
: theme.highlightColor.withOpacity(0.2),
borderRadius: const BorderRadius.all(Radius.circular(30)),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
feedBack(),
onTap?.call(),
},
onLongPress: () {
feedBack();
onLongPress?.call();
},
child: Padding(
padding: const EdgeInsets.fromLTRB(15, 7, 15, 7),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon!.icon!,
size: 13,
color: selectStatus
? theme.colorScheme.primary
: theme.colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
],
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
text ?? '',
key: ValueKey<String>(text ?? ''),
style: TextStyle(
color: selectStatus ? theme.colorScheme.primary : null,
fontSize: theme.textTheme.labelMedium!.fontSize),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:PiliPlus/utils/feed_back.dart';
class MenuRow extends StatelessWidget {
const MenuRow({
super.key,
this.loadingStatus,
});
final bool? loadingStatus;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
color: theme.colorScheme.surface,
padding: const EdgeInsets.only(top: 9, bottom: 9, left: 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: [
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '推荐',
selectStatus: false,
),
const SizedBox(width: 8),
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '弹幕',
selectStatus: false,
),
const SizedBox(width: 8),
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '评论列表',
selectStatus: false,
),
const SizedBox(width: 8),
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '播放列表',
selectStatus: false,
),
]),
),
);
}
Widget actionRowLineItem(
ThemeData theme, Function? onTap, bool? loadingStatus, String? text,
{bool selectStatus = false}) {
return Material(
color: selectStatus
? theme.highlightColor.withOpacity(0.2)
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(30)),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
feedBack(),
onTap?.call(),
},
child: Container(
padding: const EdgeInsets.fromLTRB(13, 5.5, 13, 4.5),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(30)),
border: Border.all(
color: selectStatus
? Colors.transparent
: theme.highlightColor.withOpacity(0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Text(
text!,
style: TextStyle(
fontSize: 13,
color: selectStatus
? theme.colorScheme.onSurface
: theme.colorScheme.outline),
),
),
],
),
),
),
);
}
}
class ActionRowLineItem extends StatelessWidget {
const ActionRowLineItem({
super.key,
required this.selectStatus,
this.onTap,
this.text,
this.loadingStatus = false,
this.iconData,
this.icon,
});
final bool selectStatus;
final Function? onTap;
final bool? loadingStatus;
final String? text;
final IconData? iconData;
final Widget? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: selectStatus
? theme.colorScheme.secondaryContainer
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(30)),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
feedBack(),
onTap?.call(),
},
child: Container(
padding: const EdgeInsets.fromLTRB(13, 5.5, 13, 4.5),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(30)),
border: Border.all(
color: selectStatus
? Colors.transparent
: theme.colorScheme.secondaryContainer,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (iconData != null)
Icon(
iconData,
size: 13,
color: selectStatus
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.outline,
)
else if (icon != null)
icon!,
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Text(
text!,
style: TextStyle(
fontSize: 13,
color: selectStatus
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.outline),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'dart:async';
import 'dart:math';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/utils/id_utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/models/video_detail_res.dart';
class PagesPanel extends StatefulWidget {
const PagesPanel({
super.key,
this.list,
this.cover,
required this.bvid,
required this.heroTag,
this.showEpisodes,
required this.videoIntroController,
});
final List<Part>? list;
final String? cover;
final String bvid;
final String heroTag;
final Function? showEpisodes;
final VideoIntroController videoIntroController;
@override
State<PagesPanel> createState() => _PagesPanelState();
}
class _PagesPanelState extends State<PagesPanel> {
late int cid;
int pageIndex = -1;
late VideoDetailController _videoDetailController;
final ScrollController _scrollController = ScrollController();
StreamSubscription? _listener;
List<Part> get pages =>
widget.list ?? widget.videoIntroController.videoDetail.value.pages!;
@override
void initState() {
super.initState();
_videoDetailController =
Get.find<VideoDetailController>(tag: widget.heroTag);
if (widget.list == null) {
cid = widget.videoIntroController.lastPlayCid.value;
pageIndex = pages.indexWhere((Part e) => e.cid == cid);
_listener = _videoDetailController.cid.listen((int cid) {
this.cid = cid;
pageIndex = max(0, pages.indexWhere((Part e) => e.cid == cid));
if (!mounted) return;
setState(() {});
jumpToCurr();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
jumpToCurr();
});
}
}
void jumpToCurr() {
if (!_scrollController.hasClients || pages.isEmpty) {
return;
}
const double itemWidth = 150;
final double targetOffset = (pageIndex * itemWidth - itemWidth / 2).clamp(
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent);
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_listener?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: <Widget>[
if (widget.showEpisodes != null)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('视频选集 '),
Expanded(
child: Text(
' 正在播放:${pages[pageIndex].pagePart}',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.outline,
),
),
),
const SizedBox(width: 10),
SizedBox(
height: 34,
child: TextButton(
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () => widget.showEpisodes!(
null,
null,
pages,
widget.bvid,
IdUtils.bv2av(widget.bvid),
cid,
),
child: Text(
'${pages.length}',
style: const TextStyle(fontSize: 13),
),
),
),
],
),
),
SizedBox(
height: 35,
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: pages.length,
itemExtent: 150,
itemBuilder: (BuildContext context, int i) {
bool isCurrentIndex = pageIndex == i;
return Container(
width: 150,
margin: EdgeInsets.only(
right: i != pages.length - 1 ? 10 : 0,
),
child: Material(
color: theme.colorScheme.onInverseSurface,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () {
if (widget.showEpisodes == null) {
Get.back();
}
widget.videoIntroController.changeSeasonOrbangu(
null,
widget.bvid,
pages[i].cid,
IdUtils.bv2av(widget.bvid),
widget.cover,
);
if (widget.list != null &&
widget.videoIntroController.videoDetail.value
.ugcSeason !=
null) {
_videoDetailController.seasonCid = pages.first.cid;
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 8),
child: Row(
children: <Widget>[
if (isCurrentIndex) ...<Widget>[
Image.asset(
'assets/images/live.png',
color: theme.colorScheme.primary,
height: 12,
semanticLabel: "正在播放:",
),
const SizedBox(width: 6)
],
Expanded(
child: Text(
pages[i].pagePart!,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: isCurrentIndex
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
))
],
),
),
),
),
);
},
),
)
],
);
}
}

View File

@@ -0,0 +1,171 @@
import 'dart:async';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/models/video_detail_res.dart';
class SeasonPanel extends StatefulWidget {
const SeasonPanel({
super.key,
required this.changeFuc,
required this.heroTag,
required this.showEpisodes,
this.onTap,
required this.videoIntroController,
});
final Function changeFuc;
final String heroTag;
final Function showEpisodes;
final bool? onTap;
final VideoIntroController videoIntroController;
@override
State<SeasonPanel> createState() => _SeasonPanelState();
}
class _SeasonPanelState extends State<SeasonPanel> {
RxInt currentIndex = 0.obs;
late VideoDetailController _videoDetailController;
StreamSubscription? _listener;
List<EpisodeItem> episodes = <EpisodeItem>[];
VideoIntroController get videoIntroController => widget.videoIntroController;
VideoDetailData get videoDetail =>
widget.videoIntroController.videoDetail.value;
@override
void initState() {
super.initState();
_videoDetailController =
Get.find<VideoDetailController>(tag: widget.heroTag);
_videoDetailController.seasonCid =
videoIntroController.lastPlayCid.value != 0
? (videoDetail.pages?.isNotEmpty == true
? videoDetail.isPageReversed
? videoDetail.pages!.last.cid
: videoDetail.pages!.first.cid
: videoIntroController.lastPlayCid.value)
: videoDetail.isPageReversed
? videoDetail.pages!.last.cid
: videoDetail.pages!.first.cid;
/// 根据 cid 找到对应集,找到对应 episodes
/// 有多个episodes时只显示其中一个
_findEpisode();
if (episodes.isEmpty) {
return;
}
/// 取对应 season_id 的 episodes
currentIndex.value = episodes.indexWhere(
(EpisodeItem e) => e.cid == _videoDetailController.seasonCid);
_listener = _videoDetailController.cid.listen((int cid) {
if (_videoDetailController.seasonCid != cid) {
bool isPart =
videoDetail.pages?.indexWhere((item) => item.cid == cid) != -1;
if (isPart.not) {
_videoDetailController.seasonCid = cid;
}
}
_findEpisode();
currentIndex.value = episodes.indexWhere(
(EpisodeItem e) => e.cid == _videoDetailController.seasonCid);
});
}
@override
void dispose() {
_listener?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (episodes.isEmpty) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Builder(builder: (BuildContext context) {
return Container(
margin: const EdgeInsets.only(
top: 8,
left: 2,
right: 2,
),
child: Material(
color: theme.colorScheme.onInverseSurface,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: widget.onTap == false
? null
: () => widget.showEpisodes(
_videoDetailController.seasonIndex.value,
videoDetail.ugcSeason,
null,
_videoDetailController.bvid,
null,
_videoDetailController.seasonCid,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 12),
child: Row(
children: <Widget>[
Expanded(
child: Text(
'合集:${videoDetail.ugcSeason!.title!}',
style: theme.textTheme.labelMedium,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 15),
Image.asset(
'assets/images/live.png',
color: theme.colorScheme.primary,
height: 12,
semanticLabel: "正在播放:",
),
const SizedBox(width: 10),
Obx(
() => Text(
'${currentIndex.value + 1}/${episodes.length}',
style: theme.textTheme.labelMedium,
semanticsLabel:
'${currentIndex.value + 1}集,共${episodes.length}',
),
),
const SizedBox(width: 6),
const Icon(
Icons.arrow_forward_ios_outlined,
size: 13,
semanticLabel: '查看',
)
],
),
),
),
),
);
});
}
void _findEpisode() {
final List<SectionItem> sections = videoDetail.ugcSeason!.sections!;
for (int i = 0; i < sections.length; i++) {
final List<EpisodeItem> episodesList = sections[i].episodes!;
for (int j = 0; j < episodesList.length; j++) {
if (episodesList[j].cid == _videoDetailController.seasonCid) {
if (_videoDetailController.seasonIndex.value != i) {
_videoDetailController.seasonIndex.value = i;
}
episodes = episodesList;
break;
}
}
}
}
}