From f3b7ad0302b46e14db608dd083a282f01b0749b5 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Fri, 21 Apr 2023 11:12:51 +0800 Subject: [PATCH] =?UTF-8?q?mod:=20=E8=A7=86=E9=A2=91=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E3=80=81=E8=B7=B3=E8=BD=ACHero=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/widgets/video_card_v.dart | 18 +- lib/http/video.dart | 32 ++ lib/models/video_detail_res.dart | 524 ++++++++++++++++++ lib/pages/home/controller.dart | 1 - lib/pages/video/detail/controller.dart | 2 + .../video/detail/introduction/controller.dart | 47 ++ .../video/detail/introduction/index.dart | 4 + lib/pages/video/detail/introduction/view.dart | 471 ++++++++++++++++ lib/pages/video/detail/view.dart | 17 +- .../detail/widgets/expandable_section.dart | 83 +++ lib/utils/utils.dart | 5 + 11 files changed, 1188 insertions(+), 16 deletions(-) create mode 100644 lib/http/video.dart create mode 100644 lib/models/video_detail_res.dart create mode 100644 lib/pages/video/detail/introduction/controller.dart create mode 100644 lib/pages/video/detail/introduction/index.dart create mode 100644 lib/pages/video/detail/introduction/view.dart create mode 100644 lib/pages/video/detail/widgets/expandable_section.dart diff --git a/lib/common/widgets/video_card_v.dart b/lib/common/widgets/video_card_v.dart index 8481e540..6e52b2ed 100644 --- a/lib/common/widgets/video_card_v.dart +++ b/lib/common/widgets/video_card_v.dart @@ -15,6 +15,7 @@ class VideoCardV extends StatelessWidget { @override Widget build(BuildContext context) { + String heroTag = Utils.makeHeroTag(videoItem.id); return Card( elevation: 0.8, clipBehavior: Clip.hardEdge, @@ -26,7 +27,7 @@ class VideoCardV extends StatelessWidget { onTap: () async { await Future.delayed(const Duration(milliseconds: 200)); Get.toNamed('/video?aid=${videoItem.id}', - arguments: {'videoItem': videoItem}); + arguments: {'videoItem': videoItem, 'heroTag': heroTag}); }, onLongPress: () { print('长按'); @@ -46,12 +47,15 @@ class VideoCardV extends StatelessWidget { double PR = MediaQuery.of(context).devicePixelRatio; return Stack( children: [ - NetworkImgLayer( - // 指定图片尺寸 - // src: videoItem.pic + '@${(maxWidth * 2).toInt()}w', - src: videoItem.pic + '@.webp', - width: maxWidth, - height: maxHeight, + Hero( + tag: heroTag, + child: NetworkImgLayer( + // 指定图片尺寸 + // src: videoItem.pic + '@${(maxWidth * 2).toInt()}w', + src: videoItem.pic + '@.webp', + width: maxWidth, + height: maxHeight, + ), ), Positioned( left: 0, diff --git a/lib/http/video.dart b/lib/http/video.dart new file mode 100644 index 00000000..98d448bd --- /dev/null +++ b/lib/http/video.dart @@ -0,0 +1,32 @@ +import 'package:pilipala/http/api.dart'; +import 'package:pilipala/http/init.dart'; +import 'package:pilipala/models/video_detail_res.dart'; + +class VideoHttp { + // 视频信息 标题、简介 + static Future videoDetail(data) async { + var res = await Request().get(Api.videoDetail, data: data); + VideoDetailResponse result = VideoDetailResponse.fromJson(res.data); + if (result.code == 0) { + return {'status': true, 'data': result.data!}; + } else { + Map errMap = { + -400: '请求错误', + -403: '权限不足', + -404: '无视频', + 62002: '稿件不可见', + 62004: '稿件审核中', + }; + return { + 'status': false, + 'data': null, + 'msg': errMap[result.code] ?? '请求异常' + }; + } + } + + // static Future videoRecommend(data) async { + // var res = await Request().get(Api.videoRecommend, data: data); + // return res; + // } +} diff --git a/lib/models/video_detail_res.dart b/lib/models/video_detail_res.dart new file mode 100644 index 00000000..8b5180f3 --- /dev/null +++ b/lib/models/video_detail_res.dart @@ -0,0 +1,524 @@ +import 'dart:convert'; + +class VideoDetailResponse { + int? code; + String? message; + int? ttl; + VideoDetailData? data; + + VideoDetailResponse({ + this.code, + this.message, + this.ttl, + this.data, + }); + + VideoDetailResponse.fromJson(Map json) { + code = json["code"]; + message = json["message"]; + ttl = json["ttl"]; + data = json["data"] == null ? null : VideoDetailData.fromJson(json["data"]); + } + + Map toJson() { + final Map data = {}; + data["code"] = code; + data["message"] = message; + data["ttl"] = ttl; + data["data"] = data; + + return data; + } +} + +class VideoDetailData { + String? bvid; + int? aid; + int? videos; + int? tid; + String? tname; + int? copyright; + String? pic; + String? title; + int? pubdate; + int? ctime; + String? desc; + List? descV2; + int? state; + int? duration; + Map? rights; + Owner? owner; + Stat? stat; + String? videoDynamic; + int? cid; + Dimension? dimension; + dynamic premiere; + int? teenageMode; + bool? isChargeableSeason; + bool? isStory; + bool? noCache; + List? pages; + Subtitle? subtitle; + // Label? label; + bool? isSeasonDisplay; + UserGarb? userGarb; + HonorReply? honorReply; + String? likeIcon; + bool? needJumpBv; + + VideoDetailData({ + this.bvid, + this.aid, + this.videos, + this.tid, + this.tname, + this.copyright, + this.pic, + this.title, + this.pubdate, + this.ctime, + this.desc, + this.descV2, + this.state, + this.duration, + this.rights, + this.owner, + this.stat, + this.videoDynamic, + this.cid, + this.dimension, + this.premiere, + this.teenageMode, + this.isChargeableSeason, + this.isStory, + this.noCache, + this.pages, + this.subtitle, + this.isSeasonDisplay, + this.userGarb, + this.honorReply, + this.likeIcon, + this.needJumpBv, + }); + + VideoDetailData.fromJson(Map json) { + bvid = json["bvid"]; + aid = json["aid"]; + videos = json["videos"]; + tid = json["tid"]; + tname = json["tname"]; + copyright = json["copyright"]; + pic = json["pic"]; + title = json["title"]; + pubdate = json["pubdate"]; + ctime = json["ctime"]; + desc = json["desc"]; + descV2 = json["desc_v2"] == null + ? [] + : List.from(json["desc_v2"]!.map((e) => DescV2.fromJson(e))); + state = json["state"]; + duration = json["duration"]; + rights = + Map.from(json["rights"]!).map((k, v) => MapEntry(k, v)); + owner = json["owner"] == null ? null : Owner.fromJson(json["owner"]); + stat = json["stat"] == null ? null : Stat.fromJson(json["stat"]); + videoDynamic = json["dynamic"]; + cid = json["cid"]; + dimension = json["dimension"] == null + ? null + : Dimension.fromJson(json["dimension"]); + premiere = json["premiere"]; + teenageMode = json["teenage_mode"]; + isChargeableSeason = json["is_chargeable_season"]; + isStory = json["is_story"]; + noCache = json["no_cache"]; + pages = json["pages"] == null + ? [] + : List.from(json["pages"]!.map((e) => Page.fromJson(e))); + subtitle = + json["subtitle"] == null ? null : Subtitle.fromJson(json["subtitle"]); + isSeasonDisplay = json["is_season_display"]; + userGarb = + json["user_garb"] == null ? null : UserGarb.fromJson(json["user_garb"]); + honorReply = json["honor_reply"] == null + ? null + : HonorReply.fromJson(json["honor_reply"]); + likeIcon = json["like_icon"]; + needJumpBv = json["need_jump_bv"]; + } + + Map toJson() => { + "bvid": bvid, + "aid": aid, + "videos": videos, + "tid": tid, + "tname": tname, + "copyright": copyright, + "pic": pic, + "title": title, + "pubdate": pubdate, + "ctime": ctime, + "desc": desc, + "desc_v2": descV2 == null + ? [] + : List.from(descV2!.map((e) => e.toJson())), + "state": state, + "duration": duration, + "rights": + Map.from(rights!).map((k, v) => MapEntry(k, v)), + "owner": owner?.toJson(), + "stat": stat?.toJson(), + "dynamic": videoDynamic, + "cid": cid, + "dimension": dimension?.toJson(), + "premiere": premiere, + "teenage_mode": teenageMode, + "is_chargeable_season": isChargeableSeason, + "is_story": isStory, + "no_cache": noCache, + "pages": pages == null + ? [] + : List.from(pages!.map((e) => e.toJson())), + "subtitle": subtitle?.toJson(), + "is_season_display": isSeasonDisplay, + "user_garb": userGarb?.toJson(), + "honor_reply": honorReply?.toJson(), + "like_icon": likeIcon, + "need_jump_bv": needJumpBv, + }; +} + +class DescV2 { + String? rawText; + int? type; + int? bizId; + + DescV2({ + this.rawText, + this.type, + this.bizId, + }); + + fromRawJson(String str) { + return DescV2.fromJson(json.decode(str)); + } + + String toRawJson() => json.encode(toJson()); + + DescV2.fromJson(Map json) { + rawText = json["raw_text"]; + type = json["type"]; + bizId = json["biz_id"]; + } + + Map toJson() { + final Map data = {}; + + data["raw_text"] = rawText; + data["type"] = type; + data["biz_id"] = bizId; + + return data; + } +} + +class Dimension { + int? width; + int? height; + int? rotate; + + Dimension({ + this.width, + this.height, + this.rotate, + }); + + fromRawJson(String str) => Dimension.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + Dimension.fromJson(Map json) { + width = json["width"]; + height = json["height"]; + rotate = json["rotate"]; + } + + Map toJson() { + final Map data = {}; + + data["width"] = width; + data["height"] = height; + data["rotate"] = rotate; + data["data"] = data; + + return data; + } +} + +class HonorReply { + List? honor; + + HonorReply({ + this.honor, + }); + + fromRawJson(String str) => HonorReply.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + HonorReply.fromJson(Map json) { + honor = json["honor"] == null + ? [] + : List.from(json["honor"]!.map((x) => Honor.fromJson(x))); + } + + Map toJson() { + final Map data = {}; + + data["honor"] = + honor == null ? [] : List.from(honor!.map((x) => x.toJson())); + return data; + } +} + +class Honor { + int? aid; + int? type; + String? desc; + int? weeklyRecommendNum; + + Honor({ + this.aid, + this.type, + this.desc, + this.weeklyRecommendNum, + }); + + fromRawJson(String str) => Honor.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + Honor.fromJson(Map json) { + aid = json["aid"]; + type = json["type"]; + desc = json["desc"]; + weeklyRecommendNum = json["weekly_recommend_num"]; + } + + Map toJson() { + final Map data = {}; + + data["aid"] = aid; + data["type"] = type; + data["desc"] = desc; + data["weekly_recommend_num"] = weeklyRecommendNum; + + return data; + } +} + +class Owner { + int? mid; + String? name; + String? face; + + Owner({ + this.mid, + this.name, + this.face, + }); + + fromRawJson(String str) => Owner.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + Owner.fromJson(Map json) { + mid = json["mid"]; + name = json["name"]; + face = json["face"]; + } + + Map toJson() { + final Map data = {}; + data["mid"] = mid; + data["name"] = name; + data["face"] = face; + return data; + } +} + +class Page { + int? cid; + int? page; + String? from; + String? pagePart; + int? duration; + String? vid; + String? weblink; + Dimension? dimension; + String? firstFrame; + + Page({ + this.cid, + this.page, + this.from, + this.pagePart, + this.duration, + this.vid, + this.weblink, + this.dimension, + this.firstFrame, + }); + + fromRawJson(String str) => Page.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + Page.fromJson(Map json) { + cid = json["cid"]; + page = json["page"]; + from = json["from"]; + pagePart = json["part"]; + duration = json["duration"]; + vid = json["vid"]; + weblink = json["weblink"]; + dimension = json["dimension"] == null + ? null + : Dimension.fromJson(json["dimension"]); + firstFrame = json["first_frame"]; + } + + Map toJson() { + final Map data = {}; + data["cid"] = cid; + data["page"] = page; + data["from"] = from; + data["part"] = pagePart; + data["duration"] = duration; + data["vid"] = vid; + data["weblink"] = weblink; + data["dimension"] = dimension?.toJson(); + data["first_frame"] = firstFrame; + return data; + } +} + +class Stat { + int? aid; + int? view; + int? danmaku; + int? reply; + int? favorite; + int? coin; + int? share; + int? nowRank; + int? hisRank; + int? like; + int? dislike; + String? evaluation; + String? argueMsg; + + Stat({ + this.aid, + this.view, + this.danmaku, + this.reply, + this.favorite, + this.coin, + this.share, + this.nowRank, + this.hisRank, + this.like, + this.dislike, + this.evaluation, + this.argueMsg, + }); + + fromRawJson(String str) => Stat.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + Stat.fromJson(Map json) { + aid = json["aid"]; + view = json["view"]; + danmaku = json["danmaku"]; + reply = json["reply"]; + favorite = json["favorite"]; + coin = json["coin"]; + share = json["share"]; + nowRank = json["now_rank"]; + hisRank = json["his_rank"]; + like = json["like"]; + dislike = json["dislike"]; + evaluation = json["evaluation"]; + argueMsg = json["argue_msg"]; + } + + Map toJson() { + final Map data = {}; + + data["aid"] = aid; + data["view"] = view; + data["danmaku"] = danmaku; + data["reply"] = reply; + data["favorite"] = favorite; + data["coin"] = coin; + data["share"] = share; + data["now_rank"] = nowRank; + data["his_rank"] = hisRank; + data["like"] = like; + data["dislike"] = dislike; + data["evaluation"] = evaluation; + data["argue_msg"] = argueMsg; + return data; + } +} + +class Subtitle { + bool? allowSubmit; + List? list; + + Subtitle({ + this.allowSubmit, + this.list, + }); + + fromRawJson(String str) => Subtitle.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + Subtitle.fromJson(Map json) { + allowSubmit = json["allow_submit"]; + list = json["list"] == null + ? [] + : List.from(json["list"]!.map((x) => x)); + } + + Map toJson() { + final Map data = {}; + + data["allow_submit"] = allowSubmit; + data["list"] = list == null ? [] : List.from(list!.map((x) => x)); + return data; + } +} + +class UserGarb { + String? urlImageAniCut; + + UserGarb({ + this.urlImageAniCut, + }); + + fromRawJson(String str) => UserGarb.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + UserGarb.fromJson(Map json) { + urlImageAniCut = json["url_image_ani_cut"]; + } + + Map toJson() => {"url_image_ani_cut": urlImageAniCut}; +} + +class Label {} diff --git a/lib/pages/home/controller.dart b/lib/pages/home/controller.dart index a81b9591..20f166e6 100644 --- a/lib/pages/home/controller.dart +++ b/lib/pages/home/controller.dart @@ -47,7 +47,6 @@ class HomeController extends GetxController { // 上拉加载 Future onLoad() async { - await Future.delayed(const Duration(milliseconds: 500)); queryRcmdFeed('onLoad'); } diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index c0eae8b4..5feddde6 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -13,6 +13,7 @@ class VideoDetailController extends GetxController { // 请求状态 RxBool isLoading = false.obs; + String heroTag = ''; @override void onInit() { super.onInit(); @@ -24,6 +25,7 @@ class VideoDetailController extends GetxController { videoItem['pic'] = args.pic; } } + heroTag = Get.arguments['heroTag']; } } } diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart new file mode 100644 index 00000000..1480c004 --- /dev/null +++ b/lib/pages/video/detail/introduction/controller.dart @@ -0,0 +1,47 @@ +import 'package:get/get.dart'; +import 'package:pilipala/http/api.dart'; +import 'package:pilipala/http/init.dart'; +import 'package:pilipala/models/video_detail_res.dart'; + +class VideoIntroController extends GetxController { + // 视频aid + String aid = Get.parameters['aid']!; + + // 是否预渲染 骨架屏 + bool preRender = false; + + // 视频详情 上个页面传入 + Map? videoItem = {}; + + // 请求状态 + RxBool isLoading = false.obs; + + // 视频详情 请求返回 + Rx videoDetail = VideoDetailData().obs; + + @override + void onInit() { + super.onInit(); + if (Get.arguments.isNotEmpty) { + if (Get.arguments.containsKey('videoItem')) { + preRender = true; + var args = Get.arguments['videoItem']; + videoItem!['pic'] = args.pic; + videoItem!['title'] = args.title; + videoItem!['stat'] = args.stat; + videoItem!['pubdate'] = args.pubdate; + videoItem!['owner'] = args.owner; + } + } + } + + Future queryVideoDetail() async { + var res = await Request().get(Api.videoDetail, data: { + 'aid': aid, + }); + VideoDetailResponse result = VideoDetailResponse.fromJson(res.data); + videoDetail.value = result.data!; + // await Future.delayed(const Duration(seconds: 3)); + return true; + } +} diff --git a/lib/pages/video/detail/introduction/index.dart b/lib/pages/video/detail/introduction/index.dart new file mode 100644 index 00000000..60b98c3c --- /dev/null +++ b/lib/pages/video/detail/introduction/index.dart @@ -0,0 +1,4 @@ +library video_detail_introduction; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart new file mode 100644 index 00000000..4ddaa53c --- /dev/null +++ b/lib/pages/video/detail/introduction/view.dart @@ -0,0 +1,471 @@ +import 'package:flutter/rendering.dart'; +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/pages/video/detail/widgets/expandable_section.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/common/widgets/stat/danmu.dart'; +import 'package:pilipala/common/widgets/stat/view.dart'; +import 'package:pilipala/models/video_detail_res.dart'; +import 'package:pilipala/pages/video/detail/introduction/controller.dart'; +import 'package:pilipala/utils/utils.dart'; + +class VideoIntroPanel extends StatefulWidget { + const VideoIntroPanel({Key? key}) : super(key: key); + + @override + State createState() => _VideoIntroPanelState(); +} + +class _VideoIntroPanelState extends State { + final VideoIntroController videoIntroController = + Get.put(VideoIntroController()); + VideoDetailData? videoDetail; + + @override + void initState() { + super.initState(); + videoIntroController.videoDetail.listen((value) { + videoDetail = value; + }); + } + + @override + void dispose() { + videoIntroController.onClose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: videoIntroController.queryVideoDetail(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + print(snapshot.data); + if (snapshot.data) { + // 请求成功 + return _buildView(context, false, videoDetail); + } else { + // 请求错误 + return Center( + child: IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + setState(() {}); + }, + ), + ); + } + } else { + return _buildView(context, true, videoDetail); + } + }, + ); + } + + Widget _buildView(context, loadingStatus, videoDetail) { + // return CustomScrollView( + // key: const PageStorageKey('简介'), + // slivers: [ + // SliverOverlapInjector( + // handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + // VideoInfo(loadingStatus: loadingStatus, videoDetail: videoDetail), + // SliverToBoxAdapter( + // child: + // Divider(color: Theme.of(context).dividerColor.withOpacity(0.1)), + // ), + // const RecommendList() + // ], + // ); + return VideoInfo(loadingStatus: loadingStatus, videoDetail: videoDetail); + } +} + +class VideoInfo extends StatefulWidget { + bool loadingStatus = false; + VideoDetailData? videoDetail; + + VideoInfo({Key? key, required this.loadingStatus, this.videoDetail}) + : super(key: key); + + @override + State createState() => _VideoInfoState(); +} + +class _VideoInfoState extends State with TickerProviderStateMixin { + Map videoItem = Get.put(VideoIntroController()).videoItem!; + bool isExpand = false; + + /// 手动控制动画的控制器 + late AnimationController? _manualController; + + /// 手动控制 + late Animation? _manualAnimation; + + @override + void initState() { + super.initState(); + + /// 不设置重复,使用代码控制进度,动画时间1秒 + _manualController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _manualAnimation = + Tween(begin: 0.5, end: 1.5).animate(_manualController!); + } + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.only(left: 12, right: 12, top: 25), + sliver: SliverToBoxAdapter( + child: !widget.loadingStatus || videoItem.isNotEmpty + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + NetworkImgLayer( + type: 'avatar', + src: !widget.loadingStatus + ? widget.videoDetail!.owner!.face + : videoItem['owner'].face, + width: 38, + height: 38, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + ), + const SizedBox(width: 14), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(!widget.loadingStatus + ? widget.videoDetail!.owner!.name + : videoItem['owner'].name), + const SizedBox(height: 2), + // Text.rich( + // TextSpan( + // style: TextStyle( + // color: Theme.of(context) + // .colorScheme + // .outline, + // fontSize: 11), + // children: const [ + // TextSpan(text: '2.6万粉丝'), + // TextSpan(text: ' '), + // TextSpan(text: '2.6万粉丝'), + // ]), + // ), + ]), + const Spacer(), + AnimatedOpacity( + opacity: widget.loadingStatus ? 0 : 1, + duration: const Duration(milliseconds: 150), + child: SizedBox( + height: 35, + child: ElevatedButton( + onPressed: () {}, child: const Text('+ 关注')), + ), + ), + const SizedBox(width: 4), + ], + ), + const SizedBox(height: 18), + // 标题 超过两行收起 + // Container( + // color: Colors.blue[50], + // child: SizedOverflowBox( + // size: const Size(50.0, 50.0), + // alignment: AlignmentDirectional.bottomStart, + // child: Container(height: 150.0, width: 150.0, color: Colors.blue,), + // ), + // ), + // Row( + // children: [ + // Expanded( + // child: ExpandedSection( + // expand: false, + // begin: 1, + // end: 1, + // child: Text( + // !widget.loadingStatus + // ? widget.videoDetail!.title + // : videoItem['title'], + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // ), + // ), + // ), + // const SizedBox(width: 10), + // RotationTransition( + // turns: _manualAnimation!, + // child: IconButton( + // onPressed: () { + // /// 获取动画当前的值 + // var value = _manualController!.value; + + // /// 0.5代表 180弧度 + // if (value == 0) { + // _manualController!.animateTo(0.5); + // } else { + // _manualController!.animateTo(0); + // } + // setState(() { + // isExpand = !isExpand; + // }); + // }, + // icon: const Icon(Icons.expand_less)), + // ), + // ], + // ), + SizedBox( + width: double.infinity, + child: Text( + !widget.loadingStatus + ? widget.videoDetail!.title + : videoItem['title'], + // style: Theme.of(context).textTheme.titleMedium, + // maxLines: 2, + ), + ), + // const SizedBox(height: 5), + // 播放量、评论、日期 + Row( + children: [ + const SizedBox(width: 2), + StatView( + theme: 'gray', + view: !widget.loadingStatus + ? widget.videoDetail!.stat!.view + : videoItem['stat'].view, + size: 'medium', + ), + const SizedBox(width: 10), + StatDanMu( + theme: 'gray', + danmu: !widget.loadingStatus + ? widget.videoDetail!.stat!.danmaku + : videoItem['stat'].danmaku, + size: 'medium', + ), + const SizedBox(width: 10), + Text( + Utils.dateFormat( + !widget.loadingStatus + ? widget.videoDetail!.pubdate + : videoItem['pubdate'], + formatType: 'detail'), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline), + ), + const Spacer(), + RotationTransition( + turns: _manualAnimation!, + child: IconButton( + onPressed: () { + /// 获取动画当前的值 + var value = _manualController!.value; + + /// 0.5代表 180弧度 + if (value == 0) { + _manualController!.animateTo(0.5); + } else { + _manualController!.animateTo(0); + } + setState(() { + isExpand = !isExpand; + }); + }, + icon: Icon( + Icons.expand_less, + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + ], + ), + // const SizedBox(height: 5), + // 简介 默认收起 + if (!widget.loadingStatus) + ExpandedSection( + expand: isExpand, + begin: 0.0, + end: 1.0, + child: DefaultTextStyle( + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + height: 1.5, + fontSize: + Theme.of(context).textTheme.labelMedium?.fontSize, + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: SelectableRegion( + magnifierConfiguration: + const TextMagnifierConfiguration(), + focusNode: FocusNode(), + selectionControls: MaterialTextSelectionControls(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.videoDetail!.bvid!), + Text(widget.videoDetail!.desc!), + ], + ), + ), + ), + ), + ), + _actionGrid(context), + const SizedBox(height: 5), + ], + ) + : const Center(child: CircularProgressIndicator()), + ), + ); + } + + // 喜欢 投币 分享 + Widget _actionGrid(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + return Container( + color: Colors.black12, + height: constraints.maxWidth / 5, + child: GridView.count( + primary: false, + padding: const EdgeInsets.all(0), + crossAxisCount: 5, + children: [ + ActionItem( + icon: const Icon(Icons.thumb_up), + onTap: () => {}, + selectStatus: false, + loadingStatus: widget.loadingStatus, + text: !widget.loadingStatus + ? widget.videoDetail!.stat!.like!.toString() + : '-'), + ActionItem( + icon: const Icon(Icons.thumb_down), + onTap: () => {}, + selectStatus: false, + loadingStatus: widget.loadingStatus, + text: '不喜欢'), + ActionItem( + icon: const Icon(Icons.generating_tokens), + onTap: () => {}, + selectStatus: false, + loadingStatus: widget.loadingStatus, + text: !widget.loadingStatus + ? widget.videoDetail!.stat!.coin!.toString() + : '-'), + ActionItem( + icon: const Icon(Icons.star), + onTap: () => {}, + selectStatus: false, + loadingStatus: widget.loadingStatus, + text: !widget.loadingStatus + ? widget.videoDetail!.stat!.favorite!.toString() + : '-'), + ActionItem( + icon: const Icon(Icons.share), + onTap: () => {}, + selectStatus: false, + loadingStatus: widget.loadingStatus, + text: !widget.loadingStatus + ? widget.videoDetail!.stat!.share!.toString() + : '-'), + ], + ), + ); + }); + } +} + +class ActionItem extends StatelessWidget { + Icon? icon; + Function? onTap; + bool? loadingStatus; + String? text; + bool selectStatus = false; + + ActionItem({ + Key? key, + this.icon, + this.onTap, + this.loadingStatus, + this.text, + required this.selectStatus, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Material( + child: Ink( + child: InkWell( + onTap: () {}, + borderRadius: StyleString.mdRadius, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon!.icon!, + color: selectStatus + ? Theme.of(context).primaryColor + : Theme.of(context).colorScheme.outline), + const SizedBox(height: 2), + AnimatedOpacity( + opacity: loadingStatus! ? 0 : 1, + duration: const Duration(milliseconds: 200), + child: Text( + text!, + style: TextStyle( + color: selectStatus + ? Theme.of(context).primaryColor + : Theme.of(context).colorScheme.outline, + fontSize: Theme.of(context).textTheme.labelSmall?.fontSize), + ), + ), + ], + ), + ), + )); + } +} + +class RecommendList extends StatelessWidget { + const RecommendList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return Material( + child: InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 10), + child: Text( + '$index」 求推荐一些高质量的系统地介绍 ChatGPT 及相关技术的视频、文章或者书', + style: Theme.of(context) + .textTheme + .titleSmall! + .copyWith(height: 1.6), + ), + ), + ), + ); + }, childCount: 50), + ); + } +} + +class ActionGrid extends StatelessWidget { + const ActionGrid({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index fa0c99d2..d0ad3a85 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -2,6 +2,7 @@ import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/pages/video/detail/controller.dart'; +import 'package:pilipala/pages/video/detail/introduction/index.dart'; class VideoDetailPage extends StatefulWidget { const VideoDetailPage({Key? key}) : super(key: key); @@ -52,10 +53,13 @@ class _VideoDetailPageState extends State { double maxWidth = boxConstraints.maxWidth; double maxHeight = boxConstraints.maxHeight; double PR = MediaQuery.of(context).devicePixelRatio; - return NetworkImgLayer( - src: videoDetailController.videoItem['pic'], - width: maxWidth, - height: maxHeight, + return Hero( + tag: videoDetailController.heroTag, + child: NetworkImgLayer( + src: videoDetailController.videoItem['pic'], + width: maxWidth, + height: maxHeight, + ), ); }, ), @@ -112,10 +116,7 @@ class _VideoDetailPageState extends State { handle: NestedScrollView.sliverOverlapAbsorberHandleFor( context), ), - // const VideoIntroPanel(), - const SliverToBoxAdapter( - child: Text('简介'), - ) + const VideoIntroPanel(), ], ); }), diff --git a/lib/pages/video/detail/widgets/expandable_section.dart b/lib/pages/video/detail/widgets/expandable_section.dart new file mode 100644 index 00000000..c3d49a80 --- /dev/null +++ b/lib/pages/video/detail/widgets/expandable_section.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +class ExpandedSection extends StatefulWidget { + final Widget child; + final bool expand; + double begin = 0.0; + double end = 1.0; + + ExpandedSection( + {this.expand = false, + required this.child, + required this.begin, + required this.end}); + + @override + _ExpandedSectionState createState() => _ExpandedSectionState(); +} + +class _ExpandedSectionState extends State + with SingleTickerProviderStateMixin { + late AnimationController expandController; + late Animation animation; + + @override + void initState() { + super.initState(); + prepareAnimations(); + _runExpandCheck(); + } + + ///Setting up the animation + // void prepareAnimations() { + // expandController = AnimationController( + // vsync: this, duration: const Duration(milliseconds: 500)); + // animation = CurvedAnimation( + // parent: expandController, + // curve: Curves.fastOutSlowIn, + // ); + // } + + void prepareAnimations() { + expandController = AnimationController( + vsync: this, duration: const Duration(milliseconds: 400)); + Animation curve = CurvedAnimation( + parent: expandController, + curve: Curves.fastOutSlowIn, + ); + animation = Tween(begin: widget.begin, end: widget.end).animate(curve); + // animation = CurvedAnimation( + // parent: expandController, + // curve: Curves.fastOutSlowIn, + // ); + } + + void _runExpandCheck() { + if (widget.expand) { + expandController.forward(); + } else { + expandController.reverse(); + } + } + + @override + void didUpdateWidget(ExpandedSection oldWidget) { + super.didUpdateWidget(oldWidget); + _runExpandCheck(); + } + + @override + void dispose() { + expandController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + axisAlignment: -1.0, + sizeFactor: animation, + child: widget.child, + ); + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 2bd88366..084a6e6f 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,5 +1,6 @@ // 工具函数 import 'dart:io'; +import 'dart:math'; import 'package:get/get_utils/get_utils.dart'; import 'package:path_provider/path_provider.dart'; @@ -130,4 +131,8 @@ class Utils { } return date; } + + static String makeHeroTag(v) { + return v.toString() + Random().nextInt(9999).toString(); + } }