diff --git a/lib/common/widgets/html_render.dart b/lib/common/widgets/html_render.dart index bf58d78c..b2aa75ff 100644 --- a/lib/common/widgets/html_render.dart +++ b/lib/common/widgets/html_render.dart @@ -1,7 +1,9 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; -import 'package:get/get.dart'; -import 'network_img_layer.dart'; +import 'package:pilipala/plugin/pl_gallery/hero_dialog_route.dart'; +import 'package:pilipala/plugin/pl_gallery/interactiveviewer_gallery.dart'; +import 'package:pilipala/utils/highlight.dart'; // ignore: must_be_immutable class HtmlRender extends StatelessWidget { @@ -22,6 +24,20 @@ class HtmlRender extends StatelessWidget { data: htmlContent, onLinkTap: (String? url, Map buildContext, attributes) {}, extensions: [ + TagExtension( + tagsToExtend: {'pre'}, + builder: (ExtensionContext extensionContext) { + final Map attributes = extensionContext.attributes; + final String lang = attributes['data-lang'] as String; + final String code = attributes['codecontent'] as String; + List selectedLanguages = [lang.split('@').first]; + TextSpan? result = highlightExistingText(code, selectedLanguages); + if (result == null) { + return const Center(child: Text('代码块渲染失败')); + } + return SelectableText.rich(result); + }, + ), TagExtension( tagsToExtend: {'img'}, builder: (ExtensionContext extensionContext) { @@ -44,20 +60,52 @@ class HtmlRender extends StatelessWidget { if (isMall) { return const SizedBox(); } - // bool inTable = - // extensionContext.element!.previousElementSibling == null || - // extensionContext.element!.nextElementSibling == null; - // imgUrl = Utils().imageUrl(imgUrl!); - // return Image.network( - // imgUrl, - // width: isEmote ? 22 : null, - // height: isEmote ? 22 : null, - // ); - return NetworkImgLayer( - width: isEmote ? 22 : Get.size.width - 24, - height: isEmote ? 22 : 200, - src: imgUrl, + return InkWell( + onTap: () { + Navigator.of(context).push( + HeroDialogRoute( + builder: (BuildContext context) => + InteractiveviewerGallery( + sources: imgList ?? [imgUrl], + initIndex: imgList?.indexOf(imgUrl) ?? 0, + itemBuilder: ( + BuildContext context, + int index, + bool isFocus, + bool enablePageView, + ) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (enablePageView) { + Navigator.of(context).pop(); + } + }, + child: Center( + child: Hero( + tag: imgList?[index] ?? imgUrl, + child: CachedNetworkImage( + fadeInDuration: + const Duration(milliseconds: 0), + imageUrl: imgList?[index] ?? imgUrl, + fit: BoxFit.contain, + ), + ), + ), + ); + }, + onPageChanged: (int pageIndex) {}, + ), + ), + ); + }, + child: CachedNetworkImage(imageUrl: imgUrl), ); + // return NetworkImgLayer( + // width: isEmote ? 22 : Get.size.width - 24, + // height: isEmote ? 22 : 200, + // src: imgUrl, + // ); } catch (err) { return const SizedBox(); } @@ -66,7 +114,7 @@ class HtmlRender extends StatelessWidget { ], style: { 'html': Style( - fontSize: FontSize.medium, + fontSize: FontSize.large, lineHeight: LineHeight.percent(140), ), 'body': Style(margin: Margins.zero, padding: HtmlPaddings.zero), @@ -78,7 +126,7 @@ class HtmlRender extends StatelessWidget { margin: Margins.only(bottom: 10), ), 'span': Style( - fontSize: FontSize.medium, + fontSize: FontSize.large, height: Height(1.65), ), 'div': Style(height: Height.auto()), diff --git a/lib/common/widgets/video_card_v.dart b/lib/common/widgets/video_card_v.dart index 7a9ef39c..378c9f75 100644 --- a/lib/common/widgets/video_card_v.dart +++ b/lib/common/widgets/video_card_v.dart @@ -60,17 +60,13 @@ class VideoCardV extends StatelessWidget { // 动态 case 'picture': try { - String dynamicType = 'picture'; String uri = videoItem.uri; - String id = ''; if (videoItem.uri.startsWith('bilibili://article/')) { // https://www.bilibili.com/read/cv27063554 - dynamicType = 'read'; RegExp regex = RegExp(r'\d+'); Match match = regex.firstMatch(videoItem.uri)!; String matchedNumber = match.group(0)!; videoItem.param = int.parse(matchedNumber); - id = 'cv${videoItem.param}'; } if (uri.startsWith('http')) { String path = Uri.parse(uri).path; @@ -88,11 +84,10 @@ class VideoCardV extends StatelessWidget { return; } } - Get.toNamed('/htmlRender', parameters: { - 'url': uri, + Get.toNamed('/read', parameters: { 'title': videoItem.title, - 'id': id, - 'dynamicType': dynamicType + 'id': videoItem.param, + 'articleType': 'read' }); } catch (err) { SmartDialog.showToast(err.toString()); diff --git a/lib/http/api.dart b/lib/http/api.dart index 42819d7d..ff49b314 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -578,4 +578,7 @@ class Api { /// 稍后再看&收藏夹视频列表 static const String mediaList = '/x/v2/medialist/resource/list'; + + /// + static const String getViewInfo = '/x/article/viewinfo'; } diff --git a/lib/http/read.dart b/lib/http/read.dart index cc522505..68e72e59 100644 --- a/lib/http/read.dart +++ b/lib/http/read.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'package:html/parser.dart'; import 'package:pilipala/models/read/opus.dart'; -import 'init.dart'; +import 'package:pilipala/models/read/read.dart'; +import 'package:pilipala/utils/wbi_sign.dart'; +import 'index.dart'; class ReadHttp { static List extractScriptContents(String htmlContent) { @@ -57,4 +59,58 @@ class ReadHttp { 'cvId': cvId, }; } + + // 解析专栏 cv格式 + static Future parseArticleCv({required String id}) async { + var res = await Request().get( + 'https://www.bilibili.com/read/cv$id', + extra: {'ua': 'pc'}, + ); + String scriptContent = + extractScriptContents(parse(res.data).body!.outerHtml)[0]; + int startIndex = scriptContent.indexOf('{'); + int endIndex = scriptContent.lastIndexOf('};'); + String jsonContent = scriptContent.substring(startIndex, endIndex + 1); + // 解析JSON字符串为Map + Map jsonData = json.decode(jsonContent); + return { + 'status': true, + 'data': ReadDataModel.fromJson(jsonData), + }; + } + + // + static Future getViewInfo({required String id}) async { + Map params = await WbiSign().makSign({ + 'id': id, + 'mobi_app': 'pc', + 'from': 'web', + 'gaia_source': 'main_web', + 'web_location': 333.976, + }); + var res = await Request().get( + Api.getViewInfo, + data: { + 'id': id, + 'mobi_app': 'pc', + 'from': 'web', + 'gaia_source': 'main_web', + 'web_location': 333.976, + 'w_rid': params['w_rid'], + 'wts': params['wts'], + }, + ); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'], + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/search.dart b/lib/http/search.dart index 403e6a37..00e51497 100644 --- a/lib/http/search.dart +++ b/lib/http/search.dart @@ -143,7 +143,11 @@ class SearchHttp { } final dynamic res = await Request().get(Api.ab2c, data: {...data}); - return res.data['data'].first['cid']; + if (res.data['code'] == 0) { + return res.data['data'].first['cid']; + } else { + return -1; + } } static Future> bangumiInfo( diff --git a/lib/models/read/opus.dart b/lib/models/read/opus.dart index eae1f1e0..e7b9aca1 100644 --- a/lib/models/read/opus.dart +++ b/lib/models/read/opus.dart @@ -305,14 +305,17 @@ class ModuleParagraphText { class ModuleParagraphTextNode { ModuleParagraphTextNode({ this.type, + this.nodeType, this.word, }); String? type; + int? nodeType; ModuleParagraphTextNodeWord? word; ModuleParagraphTextNode.fromJson(Map json) { type = json['type']; + nodeType = json['node_type']; word = json['word'] != null ? ModuleParagraphTextNodeWord.fromJson(json['word']) : null; diff --git a/lib/models/read/read.dart b/lib/models/read/read.dart new file mode 100644 index 00000000..fe351da4 --- /dev/null +++ b/lib/models/read/read.dart @@ -0,0 +1,286 @@ +import 'package:pilipala/models/member/info.dart'; + +import 'opus.dart'; + +class ReadDataModel { + ReadDataModel({ + this.cvid, + this.readInfo, + this.readViewInfo, + this.upInfo, + this.catalogList, + this.recommendInfoList, + this.hiddenInteraction, + this.isModern, + }); + + int? cvid; + ReadInfo? readInfo; + Map? readViewInfo; + Map? upInfo; + List? catalogList; + List? recommendInfoList; + bool? hiddenInteraction; + bool? isModern; + + ReadDataModel.fromJson(Map json) { + cvid = json['cvid']; + readInfo = + json['readInfo'] != null ? ReadInfo.fromJson(json['readInfo']) : null; + readViewInfo = json['readViewInfo']; + upInfo = json['upInfo']; + if (json['catalogList'] != null) { + catalogList = []; + json['catalogList'].forEach((v) { + catalogList!.add(v); + }); + } + if (json['recommendInfoList'] != null) { + recommendInfoList = []; + json['recommendInfoList'].forEach((v) { + recommendInfoList!.add(v); + }); + } + hiddenInteraction = json['hiddenInteraction']; + isModern = json['isModern']; + } +} + +class ReadInfo { + ReadInfo({ + this.id, + this.category, + this.title, + this.summary, + this.bannerUrl, + this.author, + this.publishTime, + this.ctime, + this.mtime, + this.stats, + this.attributes, + this.words, + this.originImageUrls, + this.content, + this.opus, + this.dynIdStr, + this.totalArtNum, + }); + + int? id; + Map? category; + String? title; + String? summary; + String? bannerUrl; + Author? author; + int? publishTime; + int? ctime; + int? mtime; + Map? stats; + int? attributes; + int? words; + List? originImageUrls; + String? content; + Opus? opus; + String? dynIdStr; + int? totalArtNum; + + ReadInfo.fromJson(Map json) { + id = json['id']; + category = json['category']; + title = json['title']; + summary = json['summary']; + bannerUrl = json['banner_url']; + author = Author.fromJson(json['author']); + publishTime = json['publish_time']; + ctime = json['ctime']; + mtime = json['mtime']; + stats = json['stats']; + attributes = json['attributes']; + words = json['words']; + if (json['origin_image_urls'] != null) { + originImageUrls = []; + json['origin_image_urls'].forEach((v) { + originImageUrls!.add(v); + }); + } + content = json['content']; + opus = json['opus'] != null ? Opus.fromJson(json['opus']) : null; + dynIdStr = json['dyn_id_str']; + totalArtNum = json['total_art_num']; + } +} + +class Author { + Author({ + this.mid, + this.name, + this.face, + this.vip, + this.fans, + this.level, + }); + + int? mid; + String? name; + String? face; + Vip? vip; + int? fans; + int? level; + + Author.fromJson(Map json) { + mid = json['mid']; + name = json['name']; + face = json['face']; + vip = json['vip'] != null ? Vip.fromJson(json['vip']) : null; + fans = json['fans']; + level = json['level']; + } +} + +class Opus { + // "opus_id": 976625853207150600, + // "opus_source": 2, + // "title": "真的很想骂人 但又没什么好骂的", + // "content": { + // "paragraphs": [{ + // "para_type": 1, + // "text": { + // "nodes": [{ + // "node_type": 1, + // "word": { + // "words": "21年玩到今年4月的号没了 ow1的时候45的号 玩了三年 后面第9赛季一个英杰5的号(虽然是偷的 但我任何违规行为都没有还是给我封了) 最近玩的号叫velleity 只和队友打天梯以及训练赛 又没了 连带着我一个一把没玩过只玩过一场训练赛的小号也没了 实在是无话可说了...", + // "font_size": 17, + // "style": {}, + // "font_level": "regular" + // } + // }] + // } + // }, { + // "para_type": 2, + // "pic": { + // "pics": [{ + // "url": "https:\u002F\u002Fi0.hdslb.com\u002Fbfs\u002Fnew_dyn\u002Fba4e57459451fe74dcb70fd20bde9823316082117.jpg", + // "width": 1600, + // "height": 1000, + // "size": 588.482421875 + // }], + // "style": 1 + // } + // }, { + // "para_type": 1, + // "text": { + // "nodes": [{ + // "node_type": 1, + // "word": { + // "words": "\n", + // "font_size": 17, + // "style": {}, + // "font_level": "regular" + // } + // }] + // } + // }, { + // "para_type": 2, + // "pic": { + // "pics": [{ + // "url": "https:\u002F\u002Fi0.hdslb.com\u002Fbfs\u002Fnew_dyn\u002F0945be6b621091ddb8189482a87a36fb316082117.jpg", + // "width": 1600, + // "height": 1002, + // "size": 665.7861328125 + // }], + // "style": 1 + // } + // }, { + // "para_type": 1, + // "text": { + // "nodes": [{ + // "node_type": 1, + // "word": { + // "words": "\n", + // "font_size": 17, + // "style": {}, + // "font_level": "regular" + // } + // }] + // } + // }, { + // "para_type": 2, + // "pic": { + // "pics": [{ + // "url": "https:\u002F\u002Fi0.hdslb.com\u002Fbfs\u002Fnew_dyn\u002Ffa60649f8786578a764a1e68a2c5d23f316082117.jpg", + // "width": 1600, + // "height": 999, + // "size": 332.970703125 + // }], + // "style": 1 + // } + // }, { + // "para_type": 1, + // "text": { + // "nodes": [{ + // "node_type": 1, + // "word": { + // "words": "\n", + // "font_size": 17, + // "style": {}, + // "font_level": "regular" + // } + // }] + // } + // }] + // }, + // "pub_info": { + // "uid": 316082117, + // "pub_time": 1726226826 + // }, + // "article": { + // "category_id": 15, + // "cover": [{ + // "url": "https:\u002F\u002Fi0.hdslb.com\u002Fbfs\u002Fnew_dyn\u002Fbanner\u002Feb10074186a62f98c18e1b5b9deb38be316082117.png", + // "width": 1071, + // "height": 315, + // "size": 225.625 + // }] + // }, + // "version": { + // "cvid": 38660379, + // "version_id": 101683514411343360 + // } + Opus({ + this.opusId, + this.opusSource, + this.title, + this.content, + }); + + int? opusId; + int? opusSource; + String? title; + Content? content; + + Opus.fromJson(Map json) { + opusId = json['opus_id']; + opusSource = json['opus_source']; + title = json['title']; + content = + json['content'] != null ? Content.fromJson(json['content']) : null; + } +} + +class Content { + Content({ + this.paragraphs, + }); + + List? paragraphs; + + Content.fromJson(Map json) { + if (json['paragraphs'] != null) { + paragraphs = []; + json['paragraphs'].forEach((v) { + paragraphs!.add(ModuleParagraph.fromJson(v)); + }); + } + } +} diff --git a/lib/pages/dynamics/controller.dart b/lib/pages/dynamics/controller.dart index d9932659..df5b154e 100644 --- a/lib/pages/dynamics/controller.dart +++ b/lib/pages/dynamics/controller.dart @@ -154,12 +154,10 @@ class DynamicsController extends GetxController { Iterable matches = digitRegExp.allMatches(jumpUrl); String number = matches.first.group(0)!; if (jumpUrl.contains('read')) { - number = 'cv$number'; - Get.toNamed('/htmlRender', parameters: { - 'url': url, + Get.toNamed('/read', parameters: { 'title': title, 'id': number, - 'dynamicType': url.split('/')[1] + 'articleType': url.split('/')[1] }); } else { Get.toNamed('/opus', parameters: { diff --git a/lib/pages/history/widgets/item.dart b/lib/pages/history/widgets/item.dart index baebfedb..50466a6b 100644 --- a/lib/pages/history/widgets/item.dart +++ b/lib/pages/history/widgets/item.dart @@ -43,14 +43,17 @@ class HistoryItem extends StatelessWidget { } if (videoItem.history.business.contains('article')) { int cid = videoItem.history.cid ?? - // videoItem.history.oid ?? + videoItem.history.oid ?? await SearchHttp.ab2c(aid: aid, bvid: bvid); + if (cid == -1) { + return SmartDialog.showToast('无法获取文章内容'); + } Get.toNamed( - '/webview', + '/read', parameters: { - 'url': 'https://www.bilibili.com/read/cv$cid', - 'type': 'note', - 'pageTitle': videoItem.title + 'title': videoItem.title, + 'id': cid.toString(), + 'articleType': 'read', }, ); } else if (videoItem.history.business == 'live') { diff --git a/lib/pages/opus/text_helper.dart b/lib/pages/opus/text_helper.dart index b22e7db5..0ec8b088 100644 --- a/lib/pages/opus/text_helper.dart +++ b/lib/pages/opus/text_helper.dart @@ -17,26 +17,46 @@ class TextHelper { static TextSpan buildTextSpan( ModuleParagraphTextNode node, int? align, BuildContext context) { - switch (node.type) { - case 'TEXT_NODE_TYPE_WORD': - return TextSpan( - text: node.word?.words ?? '', - style: TextStyle( - fontSize: - node.word?.fontSize != null ? node.word!.fontSize! * 0.95 : 14, - fontWeight: node.word?.style?.bold != null - ? FontWeight.bold - : FontWeight.normal, - height: align == 1 ? 2 : 1.5, - color: node.word?.color != null - ? Color( - int.parse(node.word!.color!.substring(1, 7), radix: 16) + - 0xFF000000) - : Theme.of(context).colorScheme.onBackground, - ), - ); - default: - return const TextSpan(text: ''); + // 获取node的所有key + if (node.nodeType != null) { + return TextSpan( + text: node.word?.words ?? '', + style: TextStyle( + fontSize: + node.word?.fontSize != null ? node.word!.fontSize! * 0.95 : 14, + fontWeight: node.word?.style?.bold != null + ? FontWeight.bold + : FontWeight.normal, + height: align == 1 ? 2 : 1.5, + color: node.word?.color != null + ? Color(int.parse(node.word!.color!.substring(1, 7), radix: 16) + + 0xFF000000) + : Theme.of(context).colorScheme.onBackground, + ), + ); + } else { + switch (node.type) { + case 'TEXT_NODE_TYPE_WORD': + return TextSpan( + text: node.word?.words ?? '', + style: TextStyle( + fontSize: node.word?.fontSize != null + ? node.word!.fontSize! * 0.95 + : 14, + fontWeight: node.word?.style?.bold != null + ? FontWeight.bold + : FontWeight.normal, + height: align == 1 ? 2 : 1.5, + color: node.word?.color != null + ? Color( + int.parse(node.word!.color!.substring(1, 7), radix: 16) + + 0xFF000000) + : Theme.of(context).colorScheme.onBackground, + ), + ); + default: + return const TextSpan(text: ''); + } } } } diff --git a/lib/pages/read/controller.dart b/lib/pages/read/controller.dart new file mode 100644 index 00000000..a0e4ef8e --- /dev/null +++ b/lib/pages/read/controller.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/read.dart'; +import 'package:pilipala/models/read/read.dart'; +import 'package:pilipala/plugin/pl_gallery/hero_dialog_route.dart'; +import 'package:pilipala/plugin/pl_gallery/interactiveviewer_gallery.dart'; + +class ReadPageController extends GetxController { + late String url; + RxString title = ''.obs; + late String id; + late String articleType; + Rx cvData = ReadDataModel().obs; + final ScrollController scrollController = ScrollController(); + late StreamController appbarStream = StreamController.broadcast(); + + @override + void onInit() { + super.onInit(); + title.value = Get.parameters['title'] ?? ''; + id = Get.parameters['id']!; + articleType = Get.parameters['articleType']!; + scrollController.addListener(_scrollListener); + fetchViewInfo(); + } + + Future fetchCvData() async { + var res = await ReadHttp.parseArticleCv(id: id); + if (res['status']) { + cvData.value = res['data']; + title.value = cvData.value.readInfo!.title!; + } + return res; + } + + void _scrollListener() { + final double offset = scrollController.position.pixels; + if (offset > 100) { + appbarStream.add(true); + } else { + appbarStream.add(false); + } + } + + void onPreviewImg(picList, initIndex, context) { + Navigator.of(context).push( + HeroDialogRoute( + builder: (BuildContext context) => InteractiveviewerGallery( + sources: picList, + initIndex: initIndex, + itemBuilder: ( + BuildContext context, + int index, + bool isFocus, + bool enablePageView, + ) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (enablePageView) { + Navigator.of(context).pop(); + } + }, + child: Center( + child: Hero( + tag: picList[index], + child: CachedNetworkImage( + fadeInDuration: const Duration(milliseconds: 0), + imageUrl: picList[index], + fit: BoxFit.contain, + ), + ), + ), + ); + }, + onPageChanged: (int pageIndex) {}, + ), + ), + ); + } + + void fetchViewInfo() { + ReadHttp.getViewInfo(id: id); + } + + @override + void onClose() { + scrollController.removeListener(_scrollListener); + appbarStream.close(); + super.onClose(); + } +} diff --git a/lib/pages/read/index.dart b/lib/pages/read/index.dart new file mode 100644 index 00000000..3603c994 --- /dev/null +++ b/lib/pages/read/index.dart @@ -0,0 +1,4 @@ +library read; + +export 'controller.dart'; +export 'view.dart'; diff --git a/lib/pages/read/view.dart b/lib/pages/read/view.dart new file mode 100644 index 00000000..7c1e0601 --- /dev/null +++ b/lib/pages/read/view.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/html_render.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/read/opus.dart'; +import 'package:pilipala/models/read/read.dart'; +import 'package:pilipala/pages/opus/text_helper.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'controller.dart'; + +class ReadPage extends StatefulWidget { + const ReadPage({super.key}); + + @override + State createState() => _ReadPageState(); +} + +class _ReadPageState extends State { + final ReadPageController controller = Get.put(ReadPageController()); + late Future _futureBuilderFuture; + + @override + void initState() { + super.initState(); + _futureBuilderFuture = controller.fetchCvData(); + } + + List extractDataSrc(String input) { + final regex = RegExp(r'data-src="([^"]*)"'); + final matches = regex.allMatches(input); + return matches.map((match) { + final dataSrc = match.group(1)!; + return dataSrc.startsWith('//') ? 'https:$dataSrc' : dataSrc; + }).toList(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: SingleChildScrollView( + controller: controller.scrollController, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + _buildFutureContent(), + ], + ), + ), + ); + } + + AppBar _buildAppBar() { + return AppBar( + title: StreamBuilder( + stream: controller.appbarStream.stream.distinct(), + initialData: false, + builder: (BuildContext context, AsyncSnapshot snapshot) { + return AnimatedOpacity( + opacity: snapshot.data ? 1 : 0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 500), + child: Obx( + () => Text( + controller.title.value, + style: const TextStyle(fontSize: 16), + ), + ), + ); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.more_vert_rounded), + onPressed: () {}, + ), + const SizedBox(width: 16), + ], + ); + } + + Widget _buildTitle() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Obx( + () => Text( + controller.title.value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 1, + height: 1.5, + ), + ), + ), + ); + } + + Widget _buildFutureContent() { + return FutureBuilder( + future: _futureBuilderFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SizedBox(); + } + if (snapshot.data['status']) { + return _buildContent(snapshot.data['data']); + } else { + return _buildError(snapshot.data['message']); + } + } else { + return _buildLoading(); + } + }, + ); + } + + Widget _buildContent(ReadDataModel cvData) { + final List picList = _extractPicList(cvData); + final List imgList = extractDataSrc(cvData.readInfo!.content!); + + return Padding( + padding: EdgeInsets.fromLTRB( + 16, 0, 16, MediaQuery.of(context).padding.bottom + 40), + child: cvData.readInfo!.opus == null + ? _buildNonOpusContent(cvData, imgList) + : _buildOpusContent(cvData, picList), + ); + } + + List _extractPicList(ReadDataModel cvData) { + final List picList = []; + if (cvData.readInfo!.opus != null) { + final List paragraphs = + cvData.readInfo!.opus!.content!.paragraphs!; + for (var paragraph in paragraphs) { + if (paragraph.paraType == 2) { + for (var pic in paragraph.pic!.pics!) { + picList.add(pic.url!); + } + } + } + } + return picList; + } + + Widget _buildNonOpusContent(ReadDataModel cvData, List imgList) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 30), + child: _buildStatsWidget(cvData), + ), + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: _buildAuthorWidget(cvData), + ), + HtmlRender( + htmlContent: cvData.readInfo!.content!, + imgList: imgList, + ), + ], + ); + } + + Widget _buildOpusContent(ReadDataModel cvData, List picList) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 30), + child: _buildStatsWidget(cvData), + ), + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: _buildAuthorWidget(cvData), + ), + ...cvData.readInfo!.opus!.content!.paragraphs!.map( + (ModuleParagraph paragraph) { + return Column( + children: [ + if (paragraph.paraType == 1) + _buildTextParagraph(paragraph) + else if (paragraph.paraType == 2) + ..._buildPics(paragraph, picList) + else + const SizedBox(), + ], + ); + }, + ), + ], + ); + } + + Widget _buildTextParagraph(ModuleParagraph paragraph) { + return Container( + alignment: TextHelper.getAlignment(paragraph.align), + margin: const EdgeInsets.only(bottom: 10), + child: Text.rich( + TextSpan( + children: paragraph.text?.nodes?.map((node) { + return TextHelper.buildTextSpan(node, paragraph.align, context); + }).toList() ?? + [], + ), + ), + ); + } + + Widget _buildError(String message) { + return SizedBox( + height: 100, + child: Center( + child: Text(message), + ), + ); + } + + Widget _buildLoading() { + return const SizedBox( + height: 100, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + Widget _buildStatsWidget(ReadDataModel cvData) { + return Row( + children: [ + StyledText(Utils.CustomStamp_str( + timestamp: cvData.readInfo!.publishTime!, + date: 'YY-MM-DD hh:mm', + toInt: false, + )), + const SizedBox(width: 10), + StyledText('${Utils.numFormat(cvData.readInfo!.stats!['view'])}浏览'), + const StyledText(' · '), + StyledText('${cvData.readInfo!.stats!['like']}点赞'), + // const StyledText(' · '), + // StyledText('${cvData.readInfo!.stats!['reply']}评论'), + ], + ); + } + + Widget _buildAuthorWidget(ReadDataModel cvData) { + final Author author = cvData.readInfo!.author!; + return Row( + children: [ + NetworkImgLayer( + width: 48, + height: 48, + type: 'avatar', + src: author.face, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + author.name!, + style: TextStyle( + color: author.vip!.nicknameColor != null + ? Color(author.vip!.nicknameColor!) + : null, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 6), + Image.asset( + 'assets/images/lv/lv${author.level}.png', + height: 11, + ), + ], + ), + Row( + children: [ + StyledText('粉丝: ${Utils.numFormat(author.fans)}'), + const SizedBox(width: 10), + StyledText( + '文章: ${Utils.numFormat(cvData.readInfo!.totalArtNum)}'), + ], + ), + ], + ), + ], + ); + } + + List _buildPics(ModuleParagraph paragraph, List picList) { + return paragraph.pic?.pics + ?.map( + (Pic pic) => Center( + child: Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: InkWell( + onTap: () { + controller.onPreviewImg( + picList, + picList.indexOf(pic.url!), + context, + ); + }, + child: NetworkImgLayer( + src: pic.url, + width: (Get.size.width - 32) * pic.scale!, + height: + (Get.size.width - 32) * pic.scale! / pic.aspectRatio!, + type: 'emote', + ), + ), + ), + ), + ) + .toList() ?? + []; + } +} + +class StyledText extends StatelessWidget { + final String text; + + const StyledText(this.text, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.outline, + ), + ); + } +} diff --git a/lib/pages/search_panel/widgets/article_panel.dart b/lib/pages/search_panel/widgets/article_panel.dart index c7074229..dd53de66 100644 --- a/lib/pages/search_panel/widgets/article_panel.dart +++ b/lib/pages/search_panel/widgets/article_panel.dart @@ -14,11 +14,10 @@ Widget searchArticlePanel(BuildContext context, ctr, list) { itemBuilder: (context, index) { return InkWell( onTap: () { - Get.toNamed('/htmlRender', parameters: { - 'url': 'www.bilibili.com/read/cv${list[index].id}', + Get.toNamed('/read', parameters: { 'title': list[index].subTitle, - 'id': 'cv${list[index].id}', - 'dynamicType': 'read' + 'id': list[index].id.toString(), + 'articleType': 'read' }); }, child: Padding( diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index a7fda733..b104c5bc 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -10,6 +10,7 @@ import 'package:pilipala/pages/message/like/index.dart'; import 'package:pilipala/pages/message/reply/index.dart'; import 'package:pilipala/pages/message/system/index.dart'; import 'package:pilipala/pages/opus/index.dart'; +import 'package:pilipala/pages/read/index.dart'; import 'package:pilipala/pages/setting/pages/logs.dart'; import '../pages/about/index.dart'; @@ -190,6 +191,7 @@ class Routes { // 专栏 CustomGetPage(name: '/opus', page: () => const OpusPage()), + CustomGetPage(name: '/read', page: () => const ReadPage()), ]; } diff --git a/lib/utils/app_scheme.dart b/lib/utils/app_scheme.dart index 675300bb..67b8a5d5 100644 --- a/lib/utils/app_scheme.dart +++ b/lib/utils/app_scheme.dart @@ -94,12 +94,14 @@ class PiliSchame { break; case 'article': final String id = path.split('/').last.split('?').first; - Get.toNamed('/htmlRender', parameters: { - 'url': 'https://www.bilibili.com/read/cv$id', - 'title': 'cv$id', - 'id': 'cv$id', - 'dynamicType': 'read' - }); + Get.toNamed( + '/read', + parameters: { + 'title': 'cv$id', + 'id': id, + 'dynamicType': 'read', + }, + ); break; case 'pgc': if (path.contains('ep')) { @@ -240,12 +242,12 @@ class PiliSchame { break; case 'read': print('专栏'); - String id = 'cv${Utils.matchNum(query!['id']!).first}'; - Get.toNamed('/htmlRender', parameters: { + String id = Utils.matchNum(query!['id']!).first.toString(); + Get.toNamed('/read', parameters: { 'url': value.dataString!, 'title': '', 'id': id, - 'dynamicType': 'read' + 'articleType': 'read' }); break; case 'space': diff --git a/lib/utils/highlight.dart b/lib/utils/highlight.dart new file mode 100644 index 00000000..c19b2f43 --- /dev/null +++ b/lib/utils/highlight.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:re_highlight/languages/all.dart'; +import 'package:re_highlight/re_highlight.dart'; +import 'package:re_highlight/styles/all.dart'; + +TextSpan? highlightExistingText(String text, List languages) { + final Highlight highlight = Highlight(); + highlight.registerLanguages(builtinAllLanguages); + final HighlightResult result = highlight.highlightAuto(text, languages); + final TextSpanRenderer renderer = + TextSpanRenderer(const TextStyle(), builtinAllThemes['github']!); + result.render(renderer); + return renderer.span; +} diff --git a/pubspec.lock b/pubspec.lock index 6b3bc06f..b9d48c78 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1234,6 +1234,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.0" + re_highlight: + dependency: "direct main" + description: + name: re_highlight + sha256: "6c4ac3f76f939fb7ca9df013df98526634e17d8f7460e028bd23a035870024f2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.3" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 65daca1e..617b0c7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -149,6 +149,8 @@ dependencies: bottom_sheet: ^4.0.4 web_socket_channel: ^2.4.5 brotli: ^0.6.0 + # 文本语法高亮 + re_highlight: ^0.0.3 dev_dependencies: flutter_test: