diff --git a/assets/images/ai.png b/assets/images/ai.png new file mode 100644 index 00000000..19f11915 Binary files /dev/null and b/assets/images/ai.png differ diff --git a/lib/http/api.dart b/lib/http/api.dart index 14f55319..f2f06007 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -327,4 +327,13 @@ class Api { // id=849312409672744983 // features=itemOpusStyle static const String dynamicDetail = '/x/polymer/web-dynamic/v1/detail'; + + // AI总结 + /// https://api.bilibili.com/x/web-interface/view/conclusion/get? + /// bvid=BV1ju4y1s7kn& + /// cid=1296086601& + /// up_mid=4641697& + /// w_rid=1607c6c5a4a35a1297e31992220900ae& + /// wts=1697033079 + static const String aiConclusion = '/x/web-interface/view/conclusion/get'; } diff --git a/lib/http/video.dart b/lib/http/video.dart index 5ca8a280..9429a04b 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -9,9 +9,11 @@ import 'package:pilipala/models/home/rcmd/result.dart'; import 'package:pilipala/models/model_hot_video_item.dart'; import 'package:pilipala/models/model_rec_video_item.dart'; import 'package:pilipala/models/user/fav_folder.dart'; +import 'package:pilipala/models/video/ai.dart'; import 'package:pilipala/models/video/play/url.dart'; import 'package:pilipala/models/video_detail_res.dart'; import 'package:pilipala/utils/storage.dart'; +import 'package:pilipala/utils/wbi_sign.dart'; /// res.data['code'] == 0 请求正常返回结果 /// res.data['data'] 为结果 @@ -420,4 +422,23 @@ class VideoHttp { return {'status': true, 'data': res.data['data']}; } } + + static Future aiConclusion({ + String? bvid, + int? cid, + int? upMid, + }) async { + Map params = await WbiSign().makSign({ + 'bvid': bvid, + 'cid': cid, + 'up_mid': upMid, + }); + var res = await Request().get(Api.aiConclusion, data: params); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': AiConclusionModel.fromJson(res.data['data']), + }; + } + } } diff --git a/lib/models/video/ai.dart b/lib/models/video/ai.dart new file mode 100644 index 00000000..a06fa79d --- /dev/null +++ b/lib/models/video/ai.dart @@ -0,0 +1,80 @@ +class AiConclusionModel { + AiConclusionModel({ + this.code, + this.modelResult, + this.stid, + this.status, + this.likeNum, + this.dislikeNum, + }); + + int? code; + ModelResult? modelResult; + String? stid; + int? status; + int? likeNum; + int? dislikeNum; + + AiConclusionModel.fromJson(Map json) { + code = json['code']; + modelResult = ModelResult.fromJson(json['model_result']); + stid = json['stid']; + status = json['status']; + likeNum = json['like_num']; + dislikeNum = json['dislike_num']; + } +} + +class ModelResult { + ModelResult({ + this.resultType, + this.summary, + this.outline, + }); + + int? resultType; + String? summary; + List? outline; + + ModelResult.fromJson(Map json) { + resultType = json['result_type']; + summary = json['summary']; + outline = json['result_type'] == 2 + ? json['outline'] + .map((e) => OutlineItem.fromJson(e)) + .toList() + : []; + } +} + +class OutlineItem { + OutlineItem({ + this.title, + this.partOutline, + }); + + String? title; + List? partOutline; + + OutlineItem.fromJson(Map json) { + title = json['title']; + partOutline = json['part_outline'] + .map((e) => PartOutline.fromJson(e)) + .toList(); + } +} + +class PartOutline { + PartOutline({ + this.timestamp, + this.content, + }); + + int? timestamp; + String? content; + + PartOutline.fromJson(Map json) { + timestamp = json['timestamp']; + content = json['content']; + } +} diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 6c32dc33..5c959116 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -8,6 +8,7 @@ import 'package:pilipala/http/constants.dart'; import 'package:pilipala/http/user.dart'; import 'package:pilipala/http/video.dart'; import 'package:pilipala/models/user/fav_folder.dart'; +import 'package:pilipala/models/video/ai.dart'; import 'package:pilipala/models/video_detail_res.dart'; import 'package:pilipala/pages/video/detail/controller.dart'; import 'package:pilipala/pages/video/detail/reply/index.dart'; @@ -62,6 +63,7 @@ class VideoIntroController extends GetxController { Timer? timer; bool isPaused = false; String heroTag = Get.arguments['heroTag']; + late ModelResult modelResult; @override void onInit() { @@ -561,4 +563,25 @@ class VideoIntroController extends GetxController { isScrollControlled: true, ); } + + // ai总结 + Future aiConclusion() async { + SmartDialog.showLoading(msg: '正在生产ai总结'); + var res = await VideoHttp.aiConclusion( + bvid: bvid, + cid: lastPlayCid.value, + upMid: videoDetail.value.owner!.mid!, + ); + if (res['status']) { + if (res['data'].modelResult.resultType == 0) { + SmartDialog.showToast('该视频不支持ai总结'); + } + if (res['data'].modelResult.resultType == 2 || + res['data'].modelResult.resultType == 1) { + modelResult = res['data'].modelResult; + } + } + SmartDialog.dismiss(); + return res; + } } diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index ef8ab928..46d641a2 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -11,6 +11,7 @@ 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/pages/video/detail/widgets/ai_detail.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; @@ -226,6 +227,17 @@ class _VideoInfoState extends State with TickerProviderStateMixin { arguments: {'face': face, 'heroTag': memberHeroTag}); } + // ai总结 + showAiBottomSheet() { + showBottomSheet( + context: context, + enableDrag: true, + builder: (BuildContext context) { + return AiDetail(modelResult: videoIntroController.modelResult); + }, + ); + } + @override Widget build(BuildContext context) { ThemeData t = Theme.of(context); @@ -238,70 +250,91 @@ class _VideoInfoState extends State with TickerProviderStateMixin { ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => showIntroDetail(), - child: Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Text( - !loadingStatus - ? widget.videoDetail!.title - : videoItem['title'], - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - )), GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => showIntroDetail(), - child: Row( - children: [ - 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: t.colorScheme.outline, - ), - ), - const SizedBox(width: 10), - if (videoIntroController.isShowOnlineTotal) - Obx( - () => Text( - '${videoIntroController.total.value}人在看', - style: TextStyle( - fontSize: 12, - color: t.colorScheme.outline, - ), - ), - ), - ], + child: Text( + !loadingStatus + ? widget.videoDetail!.title + : videoItem['title'], + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), - const SizedBox(height: 7), + Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => showIntroDetail(), + child: Padding( + padding: const EdgeInsets.only(top: 7, bottom: 6), + child: Row( + children: [ + 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: t.colorScheme.outline, + ), + ), + const SizedBox(width: 10), + if (videoIntroController.isShowOnlineTotal) + Obx( + () => Text( + '${videoIntroController.total.value}人在看', + style: TextStyle( + fontSize: 12, + color: t.colorScheme.outline, + ), + ), + ), + ], + ), + ), + ), + Positioned( + right: 10, + top: 6, + child: GestureDetector( + onTap: () async { + var res = await videoIntroController.aiConclusion(); + if (res['status']) { + if (res['data'].modelResult.resultType == 2 || + res['data'].modelResult.resultType == 1) { + showAiBottomSheet(); + } + } + }, + child: + Image.asset('assets/images/ai.png', height: 22), + ), + ) + ], + ), // 点赞收藏转发 布局样式1 // SingleChildScrollView( // padding: const EdgeInsets.only(top: 7, bottom: 7), diff --git a/lib/pages/video/detail/widgets/ai_detail.dart b/lib/pages/video/detail/widgets/ai_detail.dart new file mode 100644 index 00000000..fb280d91 --- /dev/null +++ b/lib/pages/video/detail/widgets/ai_detail.dart @@ -0,0 +1,236 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/models/video/ai.dart'; +import 'package:pilipala/pages/video/detail/index.dart'; +import 'package:pilipala/utils/storage.dart'; +import 'package:pilipala/utils/utils.dart'; + +Box localCache = GStrorage.localCache; +late double sheetHeight; + +class AiDetail extends StatelessWidget { + final ModelResult? modelResult; + + const AiDetail({ + Key? key, + this.modelResult, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + sheetHeight = localCache.get('sheetHeight'); + return Container( + color: Theme.of(context).colorScheme.background, + padding: const EdgeInsets.only(left: 14, right: 14), + height: sheetHeight, + child: Column( + children: [ + InkWell( + onTap: () => Get.back(), + child: Container( + height: 35, + padding: const EdgeInsets.only(bottom: 2), + child: Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(3)), + ), + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + Text( + modelResult!.summary!, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + height: 1.5, + ), + ), + const SizedBox(height: 20), + ListView.builder( + shrinkWrap: true, + itemCount: modelResult!.outline!.length, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return Column( + children: [ + Text( + modelResult!.outline![index].title!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + height: 1.5, + ), + ), + const SizedBox(height: 6), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: modelResult! + .outline![index].partOutline!.length, + itemBuilder: (context, i) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + children: [ + RichText( + text: TextSpan( + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onBackground, + height: 1.5, + ), + children: [ + TextSpan( + text: Utils.tampToSeektime( + modelResult! + .outline![index] + .partOutline![i] + .timestamp!), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // 跳转到指定位置 + try { + Get.find( + tag: Get.arguments[ + 'heroTag']) + .plPlayerController + .seekTo( + Duration( + seconds: + Utils.duration( + Utils.tampToSeektime(modelResult! + .outline![ + index] + .partOutline![ + i] + .timestamp!) + .toString(), + ), + ), + ); + } catch (_) {} + }, + ), + const TextSpan(text: ' '), + TextSpan( + text: modelResult! + .outline![index] + .partOutline![i] + .content!), + ], + ), + ), + ], + ), + ], + ); + }, + ), + const SizedBox(height: 20), + ], + ); + }, + ) + ], + ), + ), + ), + ], + ), + ); + } + + InlineSpan buildContent(BuildContext context, content) { + List descV2 = content.descV2; + // type + // 1 普通文本 + // 2 @用户 + List spanChilds = List.generate(descV2.length, (index) { + final currentDesc = descV2[index]; + switch (currentDesc.type) { + case 1: + List spanChildren = []; + RegExp urlRegExp = RegExp(r'https?://\S+\b'); + Iterable matches = urlRegExp.allMatches(currentDesc.rawText); + + int previousEndIndex = 0; + for (Match match in matches) { + if (match.start > previousEndIndex) { + spanChildren.add(TextSpan( + text: currentDesc.rawText + .substring(previousEndIndex, match.start))); + } + spanChildren.add( + TextSpan( + text: match.group(0), + style: TextStyle( + color: Theme.of(context).colorScheme.primary), // 设置颜色为蓝色 + recognizer: TapGestureRecognizer() + ..onTap = () { + // 处理点击事件 + try { + Get.toNamed( + '/webview', + parameters: { + 'url': match.group(0)!, + 'type': 'url', + 'pageTitle': match.group(0)!, + }, + ); + } catch (err) { + SmartDialog.showToast(err.toString()); + } + }, + ), + ); + previousEndIndex = match.end; + } + + if (previousEndIndex < currentDesc.rawText.length) { + spanChildren.add(TextSpan( + text: currentDesc.rawText.substring(previousEndIndex))); + } + + TextSpan result = TextSpan(children: spanChildren); + return result; + case 2: + final colorSchemePrimary = Theme.of(context).colorScheme.primary; + final heroTag = Utils.makeHeroTag(currentDesc.bizId); + return TextSpan( + text: '@${currentDesc.rawText}', + style: TextStyle(color: colorSchemePrimary), + recognizer: TapGestureRecognizer() + ..onTap = () { + Get.toNamed( + '/member?mid=${currentDesc.bizId}', + arguments: {'face': '', 'heroTag': heroTag}, + ); + }, + ); + default: + return const TextSpan(); + } + }); + return TextSpan(children: spanChilds); + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index f571e10d..8982c178 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -286,4 +286,15 @@ class Utils { ); } } + + // 时间戳转时间 + static tampToSeektime(number) { + int hours = number ~/ 60; + int minutes = number % 60; + + String formattedHours = hours.toString().padLeft(2, '0'); + String formattedMinutes = minutes.toString().padLeft(2, '0'); + + return '$formattedHours:$formattedMinutes'; + } }