diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index be2b2144..c1865fd5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,9 +40,13 @@ android:label="PiliPala" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:enableOnBackInvokedCallback="true"> + xmlns:tools="http://schemas.android.com/tools" + android:enableOnBackInvokedCallback="true" + android:allowBackup="false" + android:fullBackupContent="false" + tools:replace="android:allowBackup"> + + + + + + + + + + + + + +======= +>>>>>>> main UIBackgroundModes audio diff --git a/lib/common/widgets/html_render.dart b/lib/common/widgets/html_render.dart new file mode 100644 index 00000000..2e97ceed --- /dev/null +++ b/lib/common/widgets/html_render.dart @@ -0,0 +1,96 @@ +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; + +// ignore: must_be_immutable +class HtmlRender extends StatelessWidget { + String? htmlContent; + final int? imgCount; + final List? imgList; + + HtmlRender({ + this.htmlContent, + this.imgCount, + this.imgList, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Html( + data: htmlContent, + onLinkTap: (url, buildContext, attributes) => {}, + extensions: [ + TagExtension( + tagsToExtend: {"img"}, + builder: (extensionContext) { + try { + Map attributes = extensionContext.attributes; + List key = attributes.keys.toList(); + String? imgUrl = key.contains('src') + ? attributes['src'] + : attributes['data-src']; + if (imgUrl!.startsWith('//')) { + imgUrl = 'https:$imgUrl'; + } + if (imgUrl.startsWith('http://')) { + imgUrl = imgUrl.replaceAll('http://', 'https://'); + } + imgUrl = imgUrl.contains('@') ? imgUrl.split('@').first : imgUrl; + bool isEmote = imgUrl.contains('/emote/'); + bool isMall = imgUrl.contains('/mall/'); + 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, + ); + } catch (err) { + print(err); + return const SizedBox(); + } + }, + ), + ], + style: { + "html": Style( + fontSize: FontSize.medium, + lineHeight: LineHeight.percent(140), + ), + "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero), + "a": Style( + color: Theme.of(context).colorScheme.primary, + textDecoration: TextDecoration.none, + ), + "p": Style( + margin: Margins.only(bottom: 10), + ), + "span": Style( + fontSize: FontSize.medium, + height: Height(1.65), + ), + "div": Style(height: Height.auto()), + "li > p": Style( + display: Display.inline, + ), + "li": Style( + padding: HtmlPaddings.only(bottom: 4), + textAlign: TextAlign.justify, + ), + "img": Style(margin: Margins.only(top: 4, bottom: 4)), + }, + ); + } +} diff --git a/lib/common/widgets/video_card_v.dart b/lib/common/widgets/video_card_v.dart index 02c7c217..39358fda 100644 --- a/lib/common/widgets/video_card_v.dart +++ b/lib/common/widgets/video_card_v.dart @@ -5,6 +5,7 @@ import 'package:pilipala/common/constants.dart'; import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/stat/danmu.dart'; import 'package:pilipala/common/widgets/stat/view.dart'; +import 'package:pilipala/http/dynamics.dart'; import 'package:pilipala/http/search.dart'; import 'package:pilipala/http/user.dart'; import 'package:pilipala/models/common/search_type.dart'; @@ -27,6 +28,11 @@ class VideoCardV extends StatelessWidget { this.longPressEnd, }) : super(key: key); + bool isStringNumeric(String str) { + RegExp numericRegex = RegExp(r'^\d+$'); + return numericRegex.hasMatch(str); + } + void onPushDetail(heroTag) async { String goto = videoItem.goto; switch (goto) { @@ -62,6 +68,47 @@ class VideoCardV extends StatelessWidget { 'heroTag': heroTag, }); break; + // 动态 + 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; + if (isStringNumeric(path.split('/')[1])) { + // 请求接口 + var res = + await DynamicsHttp.dynamicDetail(id: path.split('/')[1]); + if (res['status']) { + Get.toNamed('/dynamicDetail', arguments: { + 'item': res['data'], + 'floor': 1, + 'action': 'detail' + }); + } + return; + } + } + Get.toNamed('/htmlRender', parameters: { + 'url': uri, + 'title': videoItem.title, + 'id': id, + 'dynamicType': dynamicType + }); + } catch (err) { + SmartDialog.showToast(err.toString()); + } + break; default: SmartDialog.showToast(videoItem.goto); Get.toNamed( @@ -112,12 +159,22 @@ class VideoCardV extends StatelessWidget { height: maxHeight, ), ), - if (crossAxisCount == 1 && videoItem.duration != null) - PBadge( - bottom: 10, - right: 10, - text: videoItem.duration, - ) + if (videoItem.duration != null) + if (crossAxisCount == 1) ...[ + PBadge( + bottom: 10, + right: 10, + text: videoItem.duration, + ) + ] else ...[ + PBadge( + bottom: 6, + right: 7, + size: 'small', + type: 'gray', + text: videoItem.duration, + ) + ], ], ); }), @@ -174,7 +231,7 @@ class VideoContent extends StatelessWidget { ], ), if (crossAxisCount > 1) ...[ - const SizedBox(height: 3), + const SizedBox(height: 2), VideoStat( videoItem: videoItem, ), @@ -247,7 +304,7 @@ class VideoContent extends StatelessWidget { }, ), ] else ...[ - const SizedBox(height: 26) + const SizedBox(height: 24) ] ], ), @@ -268,23 +325,18 @@ class VideoStat extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - children: [ - Text( - '${videoItem.stat.view}观看', - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, - color: Theme.of(context).colorScheme.outline, - ), + return RichText( + maxLines: 1, + text: TextSpan( + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + color: Theme.of(context).colorScheme.outline, ), - Text( - ' • ${videoItem.stat.danmu}弹幕', - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, - color: Theme.of(context).colorScheme.outline, - ), - ), - ], + children: [ + TextSpan(text: '${videoItem.stat.view}观看'), + TextSpan(text: ' • ${videoItem.stat.danmu}弹幕'), + ], + ), ); } } diff --git a/lib/http/api.dart b/lib/http/api.dart index e1e011eb..042d8e11 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -97,6 +97,9 @@ class Api { // 操作用户关系 static const String relationMod = '/x/relation/modify'; + // 相互关系查询 + static const String relationSearch = '/x/space/wbi/acc/relation'; + // 评论列表 // https://api.bilibili.com/x/v2/reply/main?csrf=6e22efc1a47225ea25f901f922b5cfdd&mode=3&oid=254175381&pagination_str=%7B%22offset%22:%22%22%7D&plat=1&seek_rpid=0&type=11 static const String replyList = '/x/v2/reply'; @@ -126,12 +129,14 @@ class Api { static const String userFavFolder = '/x/v3/fav/folder/created/list'; /// 收藏夹 详情 - /// media_id int 收藏夹id + /// media_id 当前收藏夹id 搜索全部时为默认收藏夹id /// pn int 当前页 /// ps int pageSize /// keyword String 搜索词 /// order String 排序方式 view 最多播放 mtime 最近收藏 pubtime 最近投稿 /// tid int 分区id + /// platform web + /// type 0 当前收藏夹 1 全部收藏夹 // https://api.bilibili.com/x/v3/fav/resource/list?media_id=76614671&pn=1&ps=20&keyword=&order=mtime&type=0&tid=0 static const String userFavFolderDetail = '/x/v3/fav/resource/list'; @@ -164,6 +169,12 @@ class Api { // 清空历史记录 static const String clearHistory = '/x/v2/history/clear'; + // 删除某条历史记录 + static const String delHistory = '/x/v2/history/delete'; + + // 搜索历史记录 + static const String searchHistory = '/x/web-goblin/history/search'; + // 热搜 static const String hotSearchList = 'https://s.search.bilibili.com/main/hotword'; @@ -239,6 +250,9 @@ class Api { // wts=1689767832 static const String memberArchive = '/x/space/wbi/arc/search'; + // 用户动态搜索 + static const String memberDynamicSearch = '/x/space/dynamic/search'; + // 用户动态 static const String memberDynamic = '/x/polymer/web-dynamic/v1/feed/space'; @@ -285,6 +299,9 @@ class Api { // 黑名单 static const String blackLst = '/x/relation/blacks'; + // 移除黑名单 + static const String removeBlack = '/x/relation/modify'; + // github 获取最新版 static const String latestApp = 'https://api.github.com/repos/guozhigq/pilipala/releases/latest'; @@ -294,4 +311,65 @@ class Api { static const String onlineTotal = '/x/player/online/total'; static const String webDanmaku = '/x/v2/dm/web/seg.so'; + + // up主分组 + static const String followUpTag = '/x/relation/tags'; + + // 设置Up主分组 + // 0 添加至默认分组 否则使用,分割tagid + static const String addUsers = '/x/relation/tags/addUsers'; + + // 获取指定分组下的up + static const String followUpGroup = '/x/relation/tag'; + + // 获取某个动态详情 + // timezone_offset=-480 + // 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'; + + // captcha验证码 + static const String getCaptcha = + 'https://passport.bilibili.com/x/passport-login/captcha?source=main_web'; + + // web端短信验证码 + static const String smsCode = + 'https://passport.bilibili.com/x/passport-login/web/sms/send'; + + // web端验证码登录 + + // web端密码登录 + + // app端短信验证码 + static const String appSmsCode = + 'https://passport.bilibili.com/x/passport-login/sms/send'; + + // app端验证码登录 + + // 获取短信验证码 + // static const String appSafeSmsCode = + // 'https://passport.bilibili.com/x/safecenter/common/sms/send'; + + /// app端密码登录 + /// username + /// password + /// key + /// rhash + static const String loginInByPwdApi = + 'https://passport.bilibili.com/x/passport-login/oauth2/login'; + + /// 密码加密密钥 + /// disable_rcmd + /// local_id + static const getWebKey = + 'https://passport.bilibili.com/x/passport-login/web/key'; } diff --git a/lib/http/black.dart b/lib/http/black.dart index 599b088b..81a7c0c9 100644 --- a/lib/http/black.dart +++ b/lib/http/black.dart @@ -23,4 +23,31 @@ class BlackHttp { }; } } + + // 移除黑名单 + static Future removeBlack({required int fid}) async { + var res = await Request().post( + Api.removeBlack, + queryParameters: { + 'act': 6, + 'csrf': await Request.getCsrf(), + 'fid': fid, + 'jsonp': 'jsonp', + 're_src': 116, + }, + ); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': [], + 'msg': '操作成功', + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/constants.dart b/lib/http/constants.dart index 1f5319fb..cf10a606 100644 --- a/lib/http/constants.dart +++ b/lib/http/constants.dart @@ -2,4 +2,37 @@ class HttpString { static const String baseUrl = 'https://www.bilibili.com'; static const String baseApiUrl = 'https://api.bilibili.com'; static const String tUrl = 'https://api.vc.bilibili.com'; + static const List validateStatusCodes = [ + 302, + 304, + 307, + 400, + 401, + 403, + 404, + 405, + 409, + 412, + 500, + 503, + 504, + 509, + 616, + 617, + 625, + 626, + 628, + 629, + 632, + 643, + 650, + 652, + 658, + 662, + 688, + 689, + 701, + 799, + 8888 + ]; } diff --git a/lib/http/danmaku.dart b/lib/http/danmaku.dart index 020c89ea..87f08d8b 100644 --- a/lib/http/danmaku.dart +++ b/lib/http/danmaku.dart @@ -17,17 +17,11 @@ class DanmakaHttp { 'oid': cid, 'segment_index': segmentIndex, }; - - // 计算函数 - Future computeTask(Map params) async { - var response = await Request().get( - Api.webDanmaku, - data: params, - extra: {'resType': ResponseType.bytes}, - ); - return DmSegMobileReply.fromBuffer(response.data); - } - - return await compute(computeTask, params); + var response = await Request().get( + Api.webDanmaku, + data: params, + extra: {'resType': ResponseType.bytes}, + ); + return DmSegMobileReply.fromBuffer(response.data); } } diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 3d0d2506..7a22ab13 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -28,6 +28,7 @@ class DynamicsHttp { 'data': DynamicsDataModel.fromJson(res.data['data']), }; } catch (err) { + print(err); return { 'status': false, 'data': [], @@ -85,4 +86,35 @@ class DynamicsHttp { }; } } + + // + static Future dynamicDetail({ + String? id, + }) async { + var res = await Request().get(Api.dynamicDetail, data: { + 'timezone_offset': -480, + 'id': id, + 'features': 'itemOpusStyle', + }); + if (res.data['code'] == 0) { + try { + return { + 'status': true, + 'data': DynamicItemModel.fromJson(res.data['data']['item']), + }; + } catch (err) { + return { + 'status': false, + 'data': [], + 'msg': err.toString(), + }; + } + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/html.dart b/lib/http/html.dart new file mode 100644 index 00000000..41570d0a --- /dev/null +++ b/lib/http/html.dart @@ -0,0 +1,103 @@ +import 'package:html/dom.dart'; +import 'package:html/parser.dart'; +import 'package:pilipala/http/index.dart'; + +class HtmlHttp { + // article + static Future reqHtml(id, dynamicType) async { + var response = await Request().get( + "https://www.bilibili.com/opus/$id", + extra: {'ua': 'pc'}, + ); + + if (response.data.contains('Redirecting to')) { + RegExp regex = RegExp(r'//([\w\.]+)/(\w+)/(\w+)'); + Match match = regex.firstMatch(response.data)!; + String matchedString = match.group(0)!; + response = await Request().get( + 'https:$matchedString' + '/', + extra: {'ua': 'pc'}, + ); + } + try { + Document rootTree = parse(response.data); + // log(response.data.body.toString()); + Element body = rootTree.body!; + Element appDom = body.querySelector('#app')!; + Element authorHeader = appDom.querySelector('.fixed-author-header')!; + // 头像 + String avatar = authorHeader.querySelector('img')!.attributes['src']!; + avatar = 'https:${avatar.split('@')[0]}'; + String uname = authorHeader + .querySelector('.fixed-author-header__author__name')! + .text; + + // 动态详情 + Element opusDetail = appDom.querySelector('.opus-detail')!; + // 发布时间 + String updateTime = + opusDetail.querySelector('.opus-module-author__pub__text')!.text; + // + String opusContent = + opusDetail.querySelector('.opus-module-content')!.innerHtml; + String test = opusDetail + .querySelector('.horizontal-scroll-album__pic__img')! + .innerHtml; + String commentId = opusDetail + .querySelector('.bili-comment-container')! + .className + .split(' ')[1] + .split('-')[2]; + // List imgList = opusDetail.querySelectorAll('bili-album__preview__picture__img'); + return { + 'status': true, + 'avatar': avatar, + 'uname': uname, + 'updateTime': updateTime, + 'content': test + opusContent, + 'commentId': int.parse(commentId) + }; + } catch (err) { + print('err: $err'); + } + } + + // read + static Future reqReadHtml(id, dynamicType) async { + var response = await Request().get( + "https://www.bilibili.com/$dynamicType/$id/", + extra: {'ua': 'pc'}, + ); + Document rootTree = parse(response.data); + Element body = rootTree.body!; + Element appDom = body.querySelector('#app')!; + Element authorHeader = appDom.querySelector('.up-left')!; + // 头像 + // String avatar = + // authorHeader.querySelector('.bili-avatar-img')!.attributes['data-src']!; + // print(avatar); + // avatar = 'https:${avatar.split('@')[0]}'; + String uname = authorHeader.querySelector('.up-name')!.text.trim(); + // 动态详情 + Element opusDetail = appDom.querySelector('.article-content')!; + // 发布时间 + // String updateTime = + // opusDetail.querySelector('.opus-module-author__pub__text')!.text; + // print(updateTime); + + // + String opusContent = + opusDetail.querySelector('#read-article-holder')!.innerHtml; + RegExp digitRegExp = RegExp(r'\d+'); + Iterable matches = digitRegExp.allMatches(id); + String number = matches.first.group(0)!; + return { + 'status': true, + 'avatar': '', + 'uname': uname, + 'updateTime': '', + 'content': opusContent, + 'commentId': int.parse(number) + }; + } +} diff --git a/lib/http/init.dart b/lib/http/init.dart index 1e821062..6a60dca0 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -4,12 +4,13 @@ import 'dart:io'; import 'dart:async'; import 'package:dio/dio.dart'; import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/io.dart'; +import 'package:dio_http2_adapter/dio_http2_adapter.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/http/constants.dart'; import 'package:pilipala/http/interceptor.dart'; -import 'package:dio_http2_adapter/dio_http2_adapter.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; class Request { @@ -17,6 +18,11 @@ class Request { static late CookieManager cookieManager; static late final Dio dio; factory Request() => _instance; + Box setting = GStrorage.setting; + static Box localCache = GStrorage.localCache; + late dynamic enableSystemProxy; + late String systemProxyHost; + late String systemProxyPort; /// 设置cookie static setCookie() async { @@ -41,8 +47,8 @@ class Request { log("setCookie, ${e.toString()}"); } } - setOptionsHeaders(userInfo); } + setOptionsHeaders(userInfo, userInfo != null && userInfo.mid != null); if (cookie.isEmpty) { try { @@ -60,9 +66,6 @@ class Request { static Future getCsrf() async { var cookies = await cookieManager.cookieJar .loadForRequest(Uri.parse(HttpString.baseApiUrl)); - // for (var i in cookies) { - // print(i); - // } String token = ''; if (cookies.where((e) => e.name == 'bili_jct').isNotEmpty) { token = cookies.firstWhere((e) => e.name == 'bili_jct').value; @@ -70,8 +73,10 @@ class Request { return token; } - static setOptionsHeaders(userInfo) { - dio.options.headers['x-bili-mid'] = userInfo.mid.toString(); + static setOptionsHeaders(userInfo, status) { + if (status) { + dio.options.headers['x-bili-mid'] = userInfo.mid.toString(); + } dio.options.headers['env'] = 'prod'; dio.options.headers['app-key'] = 'android64'; dio.options.headers['x-bili-aurora-eid'] = 'UlMFQVcABlAH'; @@ -92,18 +97,47 @@ class Request { //响应流上前后两次接受到数据的间隔,单位为毫秒。 receiveTimeout: const Duration(milliseconds: 12000), //Http请求头. - headers: { - // 'cookie': '', - }, + headers: {}, ); + enableSystemProxy = + setting.get(SettingBoxKey.enableSystemProxy, defaultValue: false); + systemProxyHost = + localCache.get(LocalCacheKey.systemProxyHost, defaultValue: ''); + systemProxyPort = + localCache.get(LocalCacheKey.systemProxyPort, defaultValue: ''); + dio = Dio(options) + + /// fix 第三方登录 302重定向 跟iOS代理问题冲突 ..httpClientAdapter = Http2Adapter( ConnectionManager( idleTimeout: const Duration(milliseconds: 10000), - // Ignore bad certificate onClientCreate: (_, config) => config.onBadCertificate = (_) => true, ), + ) + + /// 设置代理 + ..httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + final client = HttpClient(); + // Config the client. + client.findProxy = (uri) { + if (enableSystemProxy) { + print('🌹:$systemProxyHost'); + print('🌹:$systemProxyPort'); + + // return 'PROXY host:port'; + return 'PROXY $systemProxyHost:$systemProxyPort'; + } else { + // 不设置代理 + return 'DIRECT'; + } + }; + client.badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + return client; + }, ); //添加拦截器 @@ -118,30 +152,26 @@ class Request { dio.transformer = BackgroundTransformer(); dio.options.validateStatus = (status) { - return status! >= 200 && status < 300 || status == 304 || status == 302; + return status! >= 200 && status < 300 || + HttpString.validateStatusCodes.contains(status); }; } /* * get请求 */ - get(url, {data, cacheOptions, options, cancelToken, extra}) async { + get(url, {data, options, cancelToken, extra}) async { Response response; - Options options; - String ua = 'pc'; + Options options = Options(); ResponseType resType = ResponseType.json; if (extra != null) { - ua = extra!['ua'] ?? 'pc'; resType = extra!['resType'] ?? ResponseType.json; + if (extra['ua'] != null) { + options.headers = {'user-agent': headerUa(type: extra['ua'])}; + } } - if (cacheOptions != null) { - cacheOptions.headers = {'user-agent': headerUa(ua)}; - options = cacheOptions; - } else { - options = Options(); - options.headers = {'user-agent': headerUa(ua)}; - options.responseType = resType; - } + options.responseType = resType; + try { response = await dio.get( url, @@ -208,15 +238,19 @@ class Request { token.cancel("cancelled"); } - String headerUa(ua) { + String headerUa({type = 'mob'}) { String headerUa = ''; - if (ua == 'mob') { - headerUa = Platform.isIOS - ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1' - : 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36'; + if (type == 'mob') { + if (Platform.isIOS) { + headerUa = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Mobile/15E148 Safari/604.1'; + } else { + headerUa = + 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36'; + } } else { headerUa = - 'Mozilla/5.0 (MaciMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36'; + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15'; } return headerUa; } diff --git a/lib/http/interceptor.dart b/lib/http/interceptor.dart index 4b9e8770..7b398caa 100644 --- a/lib/http/interceptor.dart +++ b/lib/http/interceptor.dart @@ -46,7 +46,10 @@ class ApiInterceptor extends Interceptor { void onError(DioException err, ErrorInterceptorHandler handler) async { // 处理网络请求错误 // handler.next(err); - SmartDialog.showToast(await dioError(err)); + SmartDialog.showToast( + await dioError(err), + displayType: SmartToastType.onlyRefresh, + ); super.onError(err, handler); } diff --git a/lib/http/login.dart b/lib/http/login.dart new file mode 100644 index 00000000..8d2a254e --- /dev/null +++ b/lib/http/login.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; + +import 'package:dio/dio.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:pilipala/http/index.dart'; +import 'package:pilipala/models/login/index.dart'; +import 'package:pilipala/utils/login.dart'; +import 'package:uuid/uuid.dart'; + +class LoginHttp { + static Future queryCaptcha() async { + var res = await Request().get(Api.getCaptcha); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': CaptchaDataModel.fromJson(res.data['data']), + }; + } else { + return {'status': false, 'data': res.message}; + } + } + + static Future sendSmsCode({ + int? cid, + required int tel, + required String token, + required String challenge, + required String validate, + required String seccode, + }) async { + var res = await Request().post( + Api.appSmsCode, + data: { + 'cid': cid, + 'tel': tel, + "source": "main_web", + 'token': token, + 'challenge': challenge, + 'validate': validate, + 'seccode': seccode, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + // headers: {'user-agent': ApiConstants.userAgent} + ), + ); + print(res); + } + + // web端验证码 + static Future sendWebSmsCode({ + int? cid, + required int tel, + required String token, + required String challenge, + required String validate, + required String seccode, + }) async { + Map data = { + 'cid': cid, + 'tel': tel, + 'token': token, + 'challenge': challenge, + 'validate': validate, + 'seccode': seccode, + }; + FormData formData = FormData.fromMap({...data}); + var res = await Request().post( + Api.smsCode, + data: formData, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + ); + print(res); + } + + // web端验证码登录 + static Future loginInByWebSmsCode() async {} + + // web端密码登录 + static Future liginInByWebPwd() async {} + + // app端验证码 + static Future sendAppSmsCode({ + int? cid, + required int tel, + required String token, + required String challenge, + required String validate, + required String seccode, + }) async { + Map data = { + 'cid': cid, + 'tel': tel, + 'login_session_id': const Uuid().v4().replaceAll('-', ''), + 'recaptcha_token': token, + 'gee_challenge': challenge, + 'gee_validate': validate, + 'gee_seccode': seccode, + 'channel': 'bili', + 'buvid': buvid(), + 'local_id': buvid(), + // 'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'statistics': { + "appId": 1, + "platform": 3, + "version": "7.52.0", + "abtest": "" + }, + }; + // FormData formData = FormData.fromMap({...data}); + var res = await Request().post( + Api.appSmsCode, + data: data, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + ); + print(res); + } + + static String buvid() { + var mac = []; + var random = Random(); + + for (var i = 0; i < 6; i++) { + var min = 0; + var max = 0xff; + var num = (random.nextInt(max - min + 1) + min).toRadixString(16); + mac.add(num); + } + + var md5Str = md5.convert(utf8.encode(mac.join(':'))).toString(); + var md5Arr = md5Str.split(''); + return 'XY${md5Arr[2]}${md5Arr[12]}${md5Arr[22]}$md5Str'; + } + + // 获取盐hash跟PubKey + static Future getWebKey() async { + var res = await Request().get(Api.getWebKey, + data: {'disable_rcmd': 0, 'local_id': LoginUtils.generateBuvid()}); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'data': {}, 'msg': res.data['message']}; + } + } + + // app端密码登录 + static Future loginInByMobPwd({ + required String tel, + required String password, + required String key, + required String rhash, + }) async { + dynamic publicKey = RSAKeyParser().parse(key); + String passwordEncryptyed = + Encrypter(RSA(publicKey: publicKey)).encrypt(rhash + password).base64; + Map data = { + 'username': tel, + 'password': passwordEncryptyed, + 'local_id': LoginUtils.generateBuvid(), + 'disable_rcmd': "0", + }; + var res = await Request().post( + Api.loginInByPwdApi, + data: data, + options: Options( + contentType: Headers.formUrlEncodedContentType, + ), + ); + print(res); + } +} diff --git a/lib/http/member.dart b/lib/http/member.dart index 995ccc23..a48dbffd 100644 --- a/lib/http/member.dart +++ b/lib/http/member.dart @@ -1,7 +1,9 @@ import 'package:pilipala/http/index.dart'; import 'package:pilipala/models/dynamics/result.dart'; +import 'package:pilipala/models/follow/result.dart'; import 'package:pilipala/models/member/archive.dart'; import 'package:pilipala/models/member/info.dart'; +import 'package:pilipala/models/member/tags.dart'; import 'package:pilipala/utils/wbi_sign.dart'; class MemberHttp { @@ -18,6 +20,7 @@ class MemberHttp { var res = await Request().get( Api.memberInfo, data: params, + extra: {'ua': 'pc'}, ); if (res.data['code'] == 0) { return { @@ -65,7 +68,7 @@ class MemberHttp { int ps = 30, int tid = 0, int? pn, - String keyword = '', + String? keyword, String order = 'pubdate', bool orderAvoided = true, }) async { @@ -74,7 +77,7 @@ class MemberHttp { 'ps': ps, 'tid': tid, 'pn': pn, - 'keyword': keyword, + 'keyword': keyword ?? '', 'order': order, 'platform': 'web', 'web_location': 1550101, @@ -83,6 +86,7 @@ class MemberHttp { var res = await Request().get( Api.memberArchive, data: params, + extra: {'ua': 'pc'}, ); if (res.data['code'] == 0) { return { @@ -119,4 +123,96 @@ class MemberHttp { }; } } + + // 搜索用户动态 + static Future memberDynamicSearch({int? pn, int? ps, int? mid}) async { + var res = await Request().get(Api.memberDynamic, data: { + 'keyword': '海拔', + 'mid': mid, + 'pn': pn, + 'ps': ps, + 'platform': 'web' + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': DynamicsDataModel.fromJson(res.data['data']), + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } + + // 查询分组 + static Future followUpTags() async { + var res = await Request().get(Api.followUpTag); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'] + .map((e) => MemberTagItemModel.fromJson(e)) + .toList() + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } + + // 设置分组 + static Future addUsers(int? fids, String? tagids) async { + var res = await Request().post(Api.addUsers, queryParameters: { + 'fids': fids, + 'tagids': tagids ?? '0', + 'csrf': await Request.getCsrf(), + }, data: { + 'cross_domain': true + }); + if (res.data['code'] == 0) { + return {'status': true, 'data': [], 'msg': '操作成功'}; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } + + // 获取某分组下的up + static Future followUpGroup( + int? mid, + int? tagid, + int? pn, + int? ps, + ) async { + var res = await Request().get(Api.followUpGroup, data: { + 'mid': mid, + 'tagid': tagid, + 'pn': pn, + 'ps': ps, + }); + if (res.data['code'] == 0) { + // FollowItemModel + return { + 'status': true, + 'data': res.data['data'] + .map((e) => FollowItemModel.fromJson(e)) + .toList() + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/reply.dart b/lib/http/reply.dart index 5dcbce6e..790a017f 100644 --- a/lib/http/reply.dart +++ b/lib/http/reply.dart @@ -26,7 +26,7 @@ class ReplyHttp { Map errMap = { -400: '请求错误', -404: '无此项', - 12002: '当前页面评论功能已关闭"', + 12002: '当前页面评论功能已关闭', 12009: '评论主体的type不合法', 12061: 'UP主已关闭评论区', }; diff --git a/lib/http/search.dart b/lib/http/search.dart index 5d99e3e0..b94ace2c 100644 --- a/lib/http/search.dart +++ b/lib/http/search.dart @@ -1,37 +1,63 @@ +import 'dart:convert'; + +import 'package:hive/hive.dart'; import 'package:pilipala/http/index.dart'; import 'package:pilipala/models/bangumi/info.dart'; import 'package:pilipala/models/common/search_type.dart'; import 'package:pilipala/models/search/hot.dart'; import 'package:pilipala/models/search/result.dart'; import 'package:pilipala/models/search/suggest.dart'; +import 'package:pilipala/utils/storage.dart'; class SearchHttp { + static Box setting = GStrorage.setting; static Future hotSearchList() async { var res = await Request().get(Api.hotSearchList); - if (res.data['code'] == 0) { + if (res.data is String) { + Map resultMap = json.decode(res.data); + if (resultMap['code'] == 0) { + return { + 'status': true, + 'data': HotSearchModel.fromJson(resultMap), + }; + } + } else if (res.data is Map && res.data['code'] == 0) { return { 'status': true, 'data': HotSearchModel.fromJson(res.data), }; - } else { - return { - 'status': false, - 'data': [], - 'msg': '请求错误 🙅', - }; } + + return { + 'status': false, + 'data': [], + 'msg': '请求错误 🙅', + }; } // 获取搜索建议 static Future searchSuggest({required term}) async { var res = await Request().get(Api.serachSuggest, data: {'term': term, 'main_ver': 'v1', 'highlight': term}); - if (res.data['code'] == 0) { - res.data['result']['term'] = term; - return { - 'status': true, - 'data': SearchSuggestModel.fromJson(res.data['result']), - }; + if (res.data is String) { + Map resultMap = json.decode(res.data); + if (resultMap['code'] == 0) { + if (resultMap['result'] is Map) { + resultMap['result']['term'] = term; + } + return { + 'status': true, + 'data': resultMap['result'] is Map + ? SearchSuggestModel.fromJson(resultMap['result']) + : [], + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': '请求错误 🙅', + }; + } } else { return { 'status': false, @@ -61,29 +87,44 @@ class SearchHttp { var res = await Request().get(Api.searchByType, data: reqData); if (res.data['code'] == 0 && res.data['data']['numPages'] > 0) { Object data; - switch (searchType) { - case SearchType.video: - data = SearchVideoModel.fromJson(res.data['data']); - break; - case SearchType.live_room: - data = SearchLiveModel.fromJson(res.data['data']); - break; - case SearchType.bili_user: - data = SearchUserModel.fromJson(res.data['data']); - break; - case SearchType.media_bangumi: - data = SearchMBangumiModel.fromJson(res.data['data']); - break; + try { + switch (searchType) { + case SearchType.video: + List blackMidsList = + setting.get(SettingBoxKey.blackMidsList, defaultValue: [-1]); + for (var i in res.data['data']['result']) { + // 屏蔽推广和拉黑用户 + i['available'] = !blackMidsList.contains(i['mid']); + } + data = SearchVideoModel.fromJson(res.data['data']); + break; + case SearchType.live_room: + data = SearchLiveModel.fromJson(res.data['data']); + break; + case SearchType.bili_user: + data = SearchUserModel.fromJson(res.data['data']); + break; + case SearchType.media_bangumi: + data = SearchMBangumiModel.fromJson(res.data['data']); + break; + case SearchType.article: + data = SearchArticleModel.fromJson(res.data['data']); + break; + } + return { + 'status': true, + 'data': data, + }; + } catch (err) { + print(err); } - return { - 'status': true, - 'data': data, - }; } else { return { 'status': false, 'data': [], - 'msg': res.data['data']['numPages'] == 0 ? '没有相关数据' : '请求错误 🙅', + 'msg': res.data['data'] != null && res.data['data']['numPages'] == 0 + ? '没有相关数据' + : res.data['message'], }; } } diff --git a/lib/http/user.dart b/lib/http/user.dart index 404502b3..1ab465e0 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -8,6 +8,7 @@ import 'package:pilipala/models/user/fav_folder.dart'; import 'package:pilipala/models/user/history.dart'; import 'package:pilipala/models/user/info.dart'; import 'package:pilipala/models/user/stat.dart'; +import 'package:pilipala/utils/wbi_sign.dart'; class UserHttp { static Future userStat({required int mid}) async { @@ -70,14 +71,15 @@ class UserHttp { required int pn, required int ps, String keyword = '', - String order = 'mtime'}) async { + String order = 'mtime', + int type = 0}) async { var res = await Request().get(Api.userFavFolderDetail, data: { 'media_id': mediaId, 'pn': pn, 'ps': ps, 'keyword': keyword, 'order': order, - 'type': 0, + 'type': type, 'tid': 0, 'platform': 'web' }); @@ -231,4 +233,64 @@ class UserHttp { return {'status': false, 'msg': res.data['message']}; } } + + // 删除历史记录 + static Future delHistory(kid) async { + var res = await Request().post( + Api.delHistory, + queryParameters: { + 'kid': kid, + 'jsonp': 'jsonp', + 'csrf': await Request.getCsrf(), + }, + ); + if (res.data['code'] == 0) { + return {'status': true, 'msg': '已删除'}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + // 相互关系查询 + static Future relationSearch(int mid) async { + Map params = await WbiSign().makSign({ + 'mid': mid, + 'token': '', + 'platform': 'web', + 'web_location': 1550101, + }); + var res = await Request().get( + Api.relationSearch, + data: { + 'mid': mid, + 'w_rid': params['w_rid'], + 'wts': params['wts'], + }, + ); + if (res.data['code'] == 0) { + // relation 主动状态 + // 被动状态 + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + // 搜索历史记录 + static Future searchHistory( + {required int pn, required String keyword}) async { + var res = await Request().get( + Api.searchHistory, + data: { + 'pn': pn, + 'keyword': keyword, + 'business': 'all', + }, + ); + if (res.data['code'] == 0) { + return {'status': true, 'data': HistoryData.fromJson(res.data['data'])}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } } diff --git a/lib/http/video.dart b/lib/http/video.dart index a6084a6c..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'] 为结果 @@ -20,6 +22,9 @@ import 'package:pilipala/utils/storage.dart'; class VideoHttp { static Box localCache = GStrorage.localCache; static Box setting = GStrorage.setting; + static bool enableRcmdDynamic = + setting.get(SettingBoxKey.enableRcmdDynamic, defaultValue: true); + static Box userInfoCache = GStrorage.userInfo; // 首页推荐视频 static Future rcmdVideoList({required int ps, required int freshIdx}) async { @@ -73,6 +78,7 @@ class VideoHttp { for (var i in res.data['data']['items']) { // 屏蔽推广和拉黑用户 if (i['card_goto'] != 'ad_av' && + (!enableRcmdDynamic ? i['card_goto'] != 'picture' : true) && (i['args'] != null && !blackMidsList.contains(i['args']['up_mid']))) { list.add(RecVideoItemAppModel.fromJson(i)); @@ -130,6 +136,11 @@ class VideoHttp { // 'platform': '', // 'high_quality': '' }; + // 免登录查看1080p + if (userInfoCache.get('userInfoCache') == null && + setting.get(SettingBoxKey.p1080, defaultValue: true)) { + data['try_look'] = 1; + } try { var res = await Request().get(Api.videoUrl, data: data); if (res.data['code'] == 0) { @@ -411,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/main.dart b/lib/main.dart index ca9318ca..98413178 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,11 @@ +<<<<<<< HEAD import 'package:audio_service/audio_service.dart'; +======= +import 'dart:io'; + +>>>>>>> main import 'package:flutter/services.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -14,6 +20,7 @@ import 'package:pilipala/pages/search/index.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/router/app_pages.dart'; import 'package:pilipala/pages/main/view.dart'; +import 'package:pilipala/services/service_locator.dart'; import 'package:pilipala/utils/app_scheme.dart'; import 'package:pilipala/utils/data.dart'; import 'package:pilipala/utils/storage.dart'; @@ -26,6 +33,7 @@ void main() async { [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]) .then((_) async { await GStrorage.init(); +<<<<<<< HEAD await AudioService.init( builder: () => MyAudioHandler(), @@ -38,6 +46,9 @@ void main() async { ), ); +======= + await setupServiceLocator(); +>>>>>>> main runApp(const MyApp()); // 小白条、导航栏沉浸 SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -74,6 +85,23 @@ class MyApp extends StatelessWidget { double textScale = setting.get(SettingBoxKey.defaultTextScale, defaultValue: 1.0); + // 强制设置高帧率 + if (Platform.isAndroid) { + try { + late List modes; + FlutterDisplayMode.supported.then((value) { + modes = value; + var storageDisplay = setting.get(SettingBoxKey.displayMode); + DisplayMode f = DisplayMode.auto; + if (storageDisplay != null) { + f = modes.firstWhere((e) => e.toString() == storageDisplay); + } + DisplayMode preferred = modes.toList().firstWhere((el) => el == f); + FlutterDisplayMode.setPreferredMode(preferred); + }); + } catch (_) {} + } + return DynamicColorBuilder( builder: ((ColorScheme? lightDynamic, ColorScheme? darkDynamic) { ColorScheme? lightColorScheme; diff --git a/lib/models/common/search_type.dart b/lib/models/common/search_type.dart index 491ee7b4..d7d13aec 100644 --- a/lib/models/common/search_type.dart +++ b/lib/models/common/search_type.dart @@ -12,20 +12,20 @@ enum SearchType { live_room, // 主播:live_user // live_user, - // 专栏:article - // article, // 话题:topic // topic, // 用户:bili_user bili_user, + // 专栏:article + article, // 相簿:photo // photo } extension SearchTypeExtension on SearchType { String get type => - ['video', 'media_bangumi', 'live_room', 'bili_user'][index]; - String get label => ['视频', '番剧', '直播间', '用户'][index]; + ['video', 'media_bangumi', 'live_room', 'bili_user', 'article'][index]; + String get label => ['视频', '番剧', '直播间', '用户', '专栏'][index]; } // 搜索类型为视频、专栏及相簿时 diff --git a/lib/models/dynamics/result.dart b/lib/models/dynamics/result.dart index 05c5245e..d8aff7b5 100644 --- a/lib/models/dynamics/result.dart +++ b/lib/models/dynamics/result.dart @@ -244,7 +244,9 @@ class Vote { choiceCnt = json['choice_cnt']; share = json['share']; defaultShare = json['default_share']; - endTime = json['end_time']; + endTime = json['end_time'] is int + ? json['end_time'] + : int.parse(json['end_time']); joinNum = json['join_num']; status = json['status']; type = json['type']; diff --git a/lib/models/follow/result.dart b/lib/models/follow/result.dart index c6656165..2f1cedf5 100644 --- a/lib/models/follow/result.dart +++ b/lib/models/follow/result.dart @@ -8,7 +8,7 @@ class FollowDataModel { List? list; FollowDataModel.fromJson(Map json) { - total = json['total']; + total = json['total'] ?? 0; list = json['list'] .map((e) => FollowItemModel.fromJson(e)) .toList(); @@ -19,7 +19,7 @@ class FollowItemModel { FollowItemModel({ this.mid, this.attribute, - this.mtime, + // this.mtime, this.tag, this.special, this.uname, @@ -30,7 +30,7 @@ class FollowItemModel { int? mid; int? attribute; - int? mtime; + // int? mtime; List? tag; int? special; String? uname; @@ -41,7 +41,7 @@ class FollowItemModel { FollowItemModel.fromJson(Map json) { mid = json['mid']; attribute = json['attribute']; - mtime = json['mtime']; + // mtime = json['mtime']; tag = json['tag']; special = json['special']; uname = json['uname']; diff --git a/lib/models/login/index.dart b/lib/models/login/index.dart new file mode 100644 index 00000000..a4f2e3c0 --- /dev/null +++ b/lib/models/login/index.dart @@ -0,0 +1,49 @@ +class CaptchaDataModel { + CaptchaDataModel({ + this.type, + this.token, + this.geetest, + this.tencent, + this.validate, + this.seccode, + }); + + String? type; + String? token; + GeetestData? geetest; + Tencent? tencent; + String? validate; + String? seccode; + + CaptchaDataModel.fromJson(Map json) { + type = json["type"]; + token = json["token"]; + geetest = + json["geetest"] != null ? GeetestData.fromJson(json["geetest"]) : null; + tencent = + json["tencent"] != null ? Tencent.fromJson(json["tencent"]) : null; + } +} + +class GeetestData { + GeetestData({ + this.challenge, + this.gt, + }); + + String? challenge; + String? gt; + + GeetestData.fromJson(Map json) { + challenge = json["challenge"]; + gt = json["gt"]; + } +} + +class Tencent { + Tencent({this.appid}); + String? appid; + Tencent.fromJson(Map json) { + appid = json["appid"]; + } +} diff --git a/lib/models/member/tags.dart b/lib/models/member/tags.dart new file mode 100644 index 00000000..33f7c1f8 --- /dev/null +++ b/lib/models/member/tags.dart @@ -0,0 +1,23 @@ +class MemberTagItemModel { + MemberTagItemModel({ + this.count, + this.name, + this.tagid, + this.tip, + this.checked, + }); + + int? count; + String? name; + int? tagid; + String? tip; + bool? checked; + + MemberTagItemModel.fromJson(Map json) { + count = json['count']; + name = json['name']; + tagid = json['tagid']; + tip = json['tip']; + checked = false; + } +} diff --git a/lib/models/search/result.dart b/lib/models/search/result.dart index 91070215..3d381ed9 100644 --- a/lib/models/search/result.dart +++ b/lib/models/search/result.dart @@ -6,6 +6,7 @@ class SearchVideoModel { List? list; SearchVideoModel.fromJson(Map json) { list = json['result'] + .where((e) => e['available'] == true) .map((e) => SearchVideoItemModel.fromJson(e)) .toList(); } @@ -17,7 +18,7 @@ class SearchVideoItemModel { this.id, this.cid, // this.author, - // this.mid, + this.mid, // this.typeid, // this.typename, this.arcurl, @@ -47,7 +48,7 @@ class SearchVideoItemModel { int? id; int? cid; // String? author; - // String? mid; + int? mid; // String? typeid; // String? typename; String? arcurl; @@ -80,6 +81,7 @@ class SearchVideoItemModel { arcurl = json['arcurl']; aid = json['aid']; bvid = json['bvid']; + mid = json['mid']; // title = json['title'].replaceAll(RegExp(r'<.*?>'), ''); title = Em.regTitle(json['title']); description = json['description']; @@ -376,3 +378,75 @@ class SearchMBangumiItemModel { indexShow = json['index_show']; } } + +class SearchArticleModel { + SearchArticleModel({this.list}); + + List? list; + + SearchArticleModel.fromJson(Map json) { + list = json['result'] != null + ? json['result'] + .map( + (e) => SearchArticleItemModel.fromJson(e)) + .toList() + : []; + } +} + +class SearchArticleItemModel { + SearchArticleItemModel({ + this.pubTime, + this.like, + this.title, + this.subTitle, + this.rankOffset, + this.mid, + this.imageUrls, + this.id, + this.categoryId, + this.view, + this.reply, + this.desc, + this.rankScore, + this.type, + this.templateId, + this.categoryName, + }); + + int? pubTime; + int? like; + List? title; + String? subTitle; + int? rankOffset; + int? mid; + List? imageUrls; + int? id; + int? categoryId; + int? view; + int? reply; + String? desc; + int? rankScore; + String? type; + int? templateId; + String? categoryName; + + SearchArticleItemModel.fromJson(Map json) { + pubTime = json['pub_time']; + like = json['like']; + title = Em.regTitle(json['title']); + subTitle = json['title'].replaceAll(RegExp(r'<[^>]*>'), ''); + rankOffset = json['rank_offset']; + mid = json['mid']; + imageUrls = json['image_urls']; + id = json['id']; + categoryId = json['category_id']; + view = json['view']; + reply = json['reply']; + desc = json['desc']; + rankScore = json['rank_score']; + type = json['type']; + templateId = json['templateId']; + categoryName = json['category_name']; + } +} diff --git a/lib/models/user/history.dart b/lib/models/user/history.dart index 669874b4..5c7c9278 100644 --- a/lib/models/user/history.dart +++ b/lib/models/user/history.dart @@ -3,17 +3,23 @@ class HistoryData { this.cursor, this.tab, this.list, + this.page, }); Cursor? cursor; List? tab; List? list; + Map? page; HistoryData.fromJson(Map json) { - cursor = Cursor.fromJson(json['cursor']); - tab = json['tab'].map((e) => HisTabItem.fromJson(e)).toList(); - list = - json['list'].map((e) => HisListItem.fromJson(e)).toList(); + cursor = json['cursor'] != null ? Cursor.fromJson(json['cursor']) : null; + tab = json['tab'] != null + ? json['tab'].map((e) => HisTabItem.fromJson(e)).toList() + : []; + list = json['list'] != null + ? json['list'].map((e) => HisListItem.fromJson(e)).toList() + : []; + page = json['page']; } } @@ -79,6 +85,7 @@ class HisListItem { this.kid, this.tagName, this.liveStatus, + this.checked, }); String? title; @@ -105,6 +112,7 @@ class HisListItem { int? kid; String? tagName; int? liveStatus; + bool? checked; HisListItem.fromJson(Map json) { title = json['title']; @@ -131,6 +139,7 @@ class HisListItem { kid = json['kid']; tagName = json['tag_name']; liveStatus = json['live_status']; + checked = false; } } 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/about/index.dart b/lib/pages/about/index.dart index 76173fae..31808e1c 100644 --- a/lib/pages/about/index.dart +++ b/lib/pages/about/index.dart @@ -184,7 +184,7 @@ class AboutController extends GetxController { // 获取远程版本 Future getRemoteApp() async { - var result = await Request().get(Api.latestApp); + var result = await Request().get(Api.latestApp, extra: {'ua': 'pc'}); data = LatestDataModel.fromJson(result.data); remoteAppInfo = data; remoteVersion.value = data.tagName!; diff --git a/lib/pages/bangumi/introduction/controller.dart b/lib/pages/bangumi/introduction/controller.dart index c027f8af..f37a3310 100644 --- a/lib/pages/bangumi/introduction/controller.dart +++ b/lib/pages/bangumi/introduction/controller.dart @@ -9,6 +9,7 @@ import 'package:pilipala/models/bangumi/info.dart'; import 'package:pilipala/models/user/fav_folder.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/pages/video/detail/reply/index.dart'; +import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/storage.dart'; @@ -21,7 +22,7 @@ class BangumiIntroController extends GetxController { ? int.parse(Get.parameters['seasonId']!) : null; var epId = Get.parameters['epId'] != null - ? int.parse(Get.parameters['epId']!) + ? int.tryParse(Get.parameters['epId']!) : null; // 是否预渲染 骨架屏 @@ -257,7 +258,7 @@ class BangumiIntroController extends GetxController { VideoDetailController videoDetailCtr = Get.find(tag: Get.arguments['heroTag']); videoDetailCtr.bvid = bvid; - videoDetailCtr.cid = cid; + videoDetailCtr.cid.value = cid; videoDetailCtr.danmakuCid.value = cid; videoDetailCtr.queryVideoUrl(); // 重新请求评论 @@ -292,4 +293,31 @@ class BangumiIntroController extends GetxController { } return result; } + + /// 列表循环或者顺序播放时,自动播放下一个 + void nextPlay() { + late List episodes; + if (bangumiDetail.value.episodes != null) { + episodes = bangumiDetail.value.episodes!; + } + VideoDetailController videoDetailCtr = + Get.find(tag: Get.arguments['heroTag']); + int currentIndex = + episodes.indexWhere((e) => e.cid == videoDetailCtr.cid.value); + int nextIndex = currentIndex + 1; + PlayRepeat platRepeat = videoDetailCtr.plPlayerController.playRepeat; + // 列表循环 + if (platRepeat == PlayRepeat.listCycle) { + if (nextIndex == episodes.length - 1) { + nextIndex = 0; + } + } + if (nextIndex <= episodes.length - 1 && + platRepeat == PlayRepeat.listOrder) {} + + int cid = episodes[nextIndex].cid!; + String bvid = episodes[nextIndex].bvid!; + int aid = episodes[nextIndex].aid!; + changeSeasonOrbangu(bvid, cid, aid); + } } diff --git a/lib/pages/bangumi/introduction/view.dart b/lib/pages/bangumi/introduction/view.dart index 7d31c108..af47d7da 100644 --- a/lib/pages/bangumi/introduction/view.dart +++ b/lib/pages/bangumi/introduction/view.dart @@ -34,10 +34,12 @@ class BangumiIntroPanel extends StatefulWidget { class _BangumiIntroPanelState extends State with AutomaticKeepAliveClientMixin { - final BangumiIntroController bangumiIntroController = - Get.put(BangumiIntroController(), tag: Get.arguments['heroTag']); + late BangumiIntroController bangumiIntroController; + late VideoDetailController videoDetailCtr; BangumiInfoModel? bangumiDetail; late Future _futureBuilderFuture; + late int cid; + late String heroTag; // 添加页面缓存 @override @@ -46,10 +48,19 @@ class _BangumiIntroPanelState extends State @override void initState() { super.initState(); + heroTag = Get.arguments['heroTag']; + cid = widget.cid!; + bangumiIntroController = Get.put(BangumiIntroController(), tag: heroTag); + videoDetailCtr = Get.find(tag: heroTag); bangumiIntroController.bangumiDetail.listen((value) { bangumiDetail = value; }); _futureBuilderFuture = bangumiIntroController.queryBangumiIntro(); + videoDetailCtr.cid.listen((p0) { + print('🐶🐶$p0'); + cid = p0; + setState(() {}); + }); } @override @@ -61,22 +72,25 @@ class _BangumiIntroPanelState extends State if (snapshot.connectionState == ConnectionState.done) { if (snapshot.data['status']) { // 请求成功 + return BangumiInfo( loadingStatus: false, bangumiDetail: bangumiDetail, + cid: cid, ); } else { // 请求错误 - return HttpError( - errMsg: snapshot.data['msg'], - fn: () => Get.back(), - ); + // return HttpError( + // errMsg: snapshot.data['msg'], + // fn: () => Get.back(), + // ); + return SizedBox(); } } else { return BangumiInfo( loadingStatus: true, bangumiDetail: bangumiDetail, - cid: widget.cid, + cid: cid, ); } }, @@ -117,6 +131,12 @@ class _BangumiInfoState extends State { bangumiItem = bangumiIntroController.bangumiItem; sheetHeight = localCache.get('sheetHeight'); cid = widget.cid!; + print('cid: $cid'); + videoDetailCtr.cid.listen((p0) { + cid = p0; + print('cid: $cid'); + setState(() {}); + }); } // 收藏 @@ -260,9 +280,15 @@ class _BangumiInfoState extends State { children: [ Text( !widget.loadingStatus - ? widget.bangumiDetail!.areas! - .first['name'] - : bangumiItem!.areas!.first['name'], + ? (widget.bangumiDetail!.areas! + .isNotEmpty + ? widget.bangumiDetail!.areas! + .first['name'] + : '') + : (bangumiItem!.areas!.isNotEmpty + ? bangumiItem! + .areas!.first['name'] + : ''), style: TextStyle( fontSize: 12, color: t.colorScheme.outline, diff --git a/lib/pages/bangumi/view.dart b/lib/pages/bangumi/view.dart index f9c3e37d..e48715eb 100644 --- a/lib/pages/bangumi/view.dart +++ b/lib/pages/bangumi/view.dart @@ -113,6 +113,9 @@ class _BangumiPageState extends State builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SizedBox(); + } Map data = snapshot.data as Map; List list = _bangumidController.bangumiFollowList; if (data['status']) { @@ -198,7 +201,7 @@ class _BangumiPageState extends State }, ), ), - const LoadingMore() + LoadingMore() ], ), ); diff --git a/lib/pages/bangumi/widgets/bangumi_panel.dart b/lib/pages/bangumi/widgets/bangumi_panel.dart index 9c55448d..bb27a38a 100644 --- a/lib/pages/bangumi/widgets/bangumi_panel.dart +++ b/lib/pages/bangumi/widgets/bangumi_panel.dart @@ -1,7 +1,9 @@ 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/bangumi/info.dart'; +import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/utils/storage.dart'; class BangumiPanel extends StatefulWidget { @@ -30,16 +32,28 @@ class _BangumiPanelState extends State { dynamic userInfo; // 默认未开通 int vipStatus = 0; + late int cid; + String heroTag = Get.arguments['heroTag']; + late final VideoDetailController videoDetailCtr; @override void initState() { super.initState(); - currentIndex = widget.pages.indexWhere((e) => e.cid == widget.cid!); + cid = widget.cid!; + currentIndex = widget.pages.indexWhere((e) => e.cid == cid); scrollToIndex(); userInfo = userInfoCache.get('userInfoCache'); if (userInfo != null) { vipStatus = userInfo.vipStatus; } + videoDetailCtr = Get.find(tag: heroTag); + + videoDetailCtr.cid.listen((p0) { + cid = p0; + setState(() {}); + currentIndex = widget.pages.indexWhere((e) => e.cid == cid); + scrollToIndex(); + }); } @override diff --git a/lib/pages/blacklist/index.dart b/lib/pages/blacklist/index.dart index 63792532..09cbaee8 100644 --- a/lib/pages/blacklist/index.dart +++ b/lib/pages/blacklist/index.dart @@ -1,4 +1,5 @@ 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/common/widgets/http_error.dart'; @@ -60,7 +61,7 @@ class _BlackListPageState extends State { centerTitle: false, title: Obx( () => Text( - '黑名单管理 (${_blackListController.blackList.length} / 5000)', + '黑名单管理 - ${_blackListController.total.value}', style: Theme.of(context).textTheme.titleMedium, ), ), @@ -104,10 +105,11 @@ class _BlackListPageState extends State { overflow: TextOverflow.ellipsis, ), dense: true, - // trailing: TextButton( - // onPressed: () {}, - // child: const Text('移除'), - // ), + trailing: TextButton( + onPressed: () => _blackListController + .removeBlack(list[index].mid), + child: const Text('移除'), + ), ); }, ), @@ -136,6 +138,7 @@ class _BlackListPageState extends State { class BlackListController extends GetxController { int currentPage = 1; int pageSize = 50; + RxInt total = 0.obs; RxList blackList = [BlackListItem()].obs; Future queryBlacklist({type = 'init'}) async { @@ -146,6 +149,7 @@ class BlackListController extends GetxController { if (result['status']) { if (type == 'init') { blackList.value = result['data'].list; + total.value = result['data'].total; } else { blackList.addAll(result['data'].list); } @@ -154,4 +158,13 @@ class BlackListController extends GetxController { } return result; } + + Future removeBlack(mid) async { + var result = await BlackHttp.removeBlack(fid: mid); + if (result['status']) { + blackList.removeWhere((e) => e.mid == mid); + total.value = total.value - 1; + SmartDialog.showToast(result['msg']); + } + } } diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart index ebe7712d..38d09e04 100644 --- a/lib/pages/danmaku/controller.dart +++ b/lib/pages/danmaku/controller.dart @@ -10,22 +10,34 @@ class PlDanmakuController { // 按 6min 分段 int segCount = 0; List dmSegList = []; - int currentSegIndex = 0; + // 已请求的段落标记 + List hasrequestSeg = []; + int currentSegIndex = 1; int currentDmIndex = 0; void calcSegment() { + dmSegList.clear(); + // 视频分段数 segCount = (videoDuration.inSeconds / (60 * 6)).ceil(); + dmSegList = List.generate( + segCount < 1 ? 1 : segCount, (index) => DmSegMobileReply()); + // 当前分段 + try { + currentSegIndex = + (playerController.position.value.inSeconds / (60 * 6)).ceil(); + currentSegIndex = currentSegIndex < 1 ? 1 : currentSegIndex; + } catch (_) {} } Future> queryDanmaku() async { - dmSegList.clear(); - for (int segIndex = 1; segIndex <= segCount; segIndex++) { - DmSegMobileReply result = - await DanmakaHttp.queryDanmaku(cid: cid, segmentIndex: segIndex); - if (result.elems.isNotEmpty) { - result.elems.sort((a, b) => (a.progress).compareTo(b.progress)); - dmSegList.add(result); - } + // dmSegList.clear(); + DmSegMobileReply result = + await DanmakaHttp.queryDanmaku(cid: cid, segmentIndex: currentSegIndex); + if (result.elems.isNotEmpty) { + result.elems.sort((a, b) => (a.progress).compareTo(b.progress)); + // dmSegList.add(result); + currentSegIndex = currentSegIndex < 1 ? 1 : currentSegIndex; + dmSegList[currentSegIndex - 1] = result; } if (dmSegList.isNotEmpty) { findClosestPositionIndex(playerController.position.value.inMilliseconds); diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 972c96c3..317d47e9 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -1,3 +1,4 @@ +import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; @@ -29,6 +30,11 @@ class _PlDanmakuState extends State { bool danmuPlayStatus = true; Box setting = GStrorage.setting; late bool enableShowDanmaku; + late List blockTypes; + late double showArea; + late double opacityVal; + late double fontSizeVal; + late double danmakuSpeedVal; @override void initState() { @@ -58,6 +64,11 @@ class _PlDanmakuState extends State { } } }); + blockTypes = playerController.blockTypes; + showArea = playerController.showArea; + opacityVal = playerController.opacityVal; + fontSizeVal = playerController.fontSizeVal; + danmakuSpeedVal = playerController.danmakuSpeedVal; } // 播放器状态监听 @@ -75,12 +86,23 @@ class _PlDanmakuState extends State { _controller!.onResume(); danmuPlayStatus = true; } - PlDanmakuController ctr = _plDanmakuController; - int currentPosition = position.inMilliseconds; - if (!playerController.isOpenDanmu.value) { return; } + PlDanmakuController ctr = _plDanmakuController; + int currentPosition = position.inMilliseconds; + blockTypes = playerController.blockTypes; + // 根据position判断是否有已缓存弹幕。没有则请求对应段 + int segIndex = (currentPosition / (6 * 60 * 1000)).ceil(); + segIndex = segIndex < 1 ? 1 : segIndex; + if (ctr.dmSegList[segIndex - 1].elems.isEmpty && + !ctr.hasrequestSeg.contains(segIndex - 1)) { + ctr.hasrequestSeg.add(segIndex - 1); + ctr.currentSegIndex = segIndex; + EasyThrottle.throttle('follow', const Duration(seconds: 1), () { + ctr.queryDanmaku(); + }); + } // 超出分段数返回 if (ctr.currentSegIndex >= ctr.dmSegList.length) { return; @@ -99,14 +121,17 @@ class _PlDanmakuState extends State { var delta = currentPosition - element.progress; if (delta >= 0 && delta < 200) { - _controller!.addItems([ - DanmakuItem( - element.content, - color: DmUtils.decimalToColor(element.color), - time: element.progress, - type: DmUtils.getPosition(element.mode), - ) - ]); + // 屏蔽彩色弹幕 + if (blockTypes.contains(6) ? element.color == 16777215 : true) { + _controller!.addItems([ + DanmakuItem( + element.content, + color: DmUtils.decimalToColor(element.color), + time: element.progress, + type: DmUtils.getPosition(element.mode), + ) + ]); + } ctr.currentDmIndex++; } else { if (!playerController.isOpenDanmu.value) { @@ -126,22 +151,30 @@ class _PlDanmakuState extends State { @override Widget build(BuildContext context) { - return Obx( - () => AnimatedOpacity( - opacity: playerController.isOpenDanmu.value ? 1 : 0, - duration: const Duration(milliseconds: 100), - child: DanmakuView( - createdController: (DanmakuController e) async { - widget.playerController.danmakuController = _controller = e; - }, - option: DanmakuOption( - fontSize: 15, - area: 0.5, - duration: 5, + return LayoutBuilder(builder: (context, box) { + double initDuration = box.maxWidth / 12; + return Obx( + () => AnimatedOpacity( + opacity: playerController.isOpenDanmu.value ? 1 : 0, + duration: const Duration(milliseconds: 100), + child: DanmakuView( + createdController: (DanmakuController e) async { + widget.playerController.danmakuController = _controller = e; + }, + option: DanmakuOption( + fontSize: 15 * fontSizeVal, + area: showArea, + opacity: opacityVal, + hideTop: blockTypes.contains(5), + hideScroll: blockTypes.contains(2), + hideBottom: blockTypes.contains(4), + duration: initDuration / + (danmakuSpeedVal * widget.playerController.playbackSpeed), + ), + statusChanged: (isPlaying) {}, ), - statusChanged: (isPlaying) {}, ), - ), - ); + ); + }); } } diff --git a/lib/pages/dynamics/controller.dart b/lib/pages/dynamics/controller.dart index 5b524510..26ba2b22 100644 --- a/lib/pages/dynamics/controller.dart +++ b/lib/pages/dynamics/controller.dart @@ -149,10 +149,30 @@ class DynamicsController extends GetxController { case 'DYNAMIC_TYPE_ARTICLE': String title = item.modules.moduleDynamic.major.opus.title; String url = item.modules.moduleDynamic.major.opus.jumpUrl; - Get.toNamed( - '/webview', - parameters: {'url': 'https:$url', 'type': 'note', 'pageTitle': title}, - ); + if (url.contains('opus') || url.contains('read')) { + RegExp digitRegExp = RegExp(r'\d+'); + Iterable matches = digitRegExp.allMatches(url); + String number = matches.first.group(0)!; + if (url.contains('read')) { + number = 'cv$number'; + } + Get.toNamed('/htmlRender', parameters: { + 'url': url.startsWith('//') ? url.split('//').last : url, + 'title': title, + 'id': number, + 'dynamicType': url.split('//').last.split('/')[1] + }); + } else { + Get.toNamed( + '/webview', + parameters: { + 'url': 'https:$url', + 'type': 'note', + 'pageTitle': title + }, + ); + } + break; case 'DYNAMIC_TYPE_PGC': print('番剧'); diff --git a/lib/pages/dynamics/deatil/controller.dart b/lib/pages/dynamics/deatil/controller.dart index 96ab65a6..62f0245d 100644 --- a/lib/pages/dynamics/deatil/controller.dart +++ b/lib/pages/dynamics/deatil/controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/http/reply.dart'; @@ -17,6 +18,7 @@ class DynamicDetailController extends GetxController { RxString noMore = ''.obs; RxList replyList = [ReplyItemModel()].obs; RxInt acount = 0.obs; + final ScrollController scrollController = ScrollController(); ReplySortType _sortType = ReplySortType.time; RxString sortTypeTitle = ReplySortType.time.titles.obs; diff --git a/lib/pages/dynamics/deatil/view.dart b/lib/pages/dynamics/deatil/view.dart index 6a779ba3..116e0d27 100644 --- a/lib/pages/dynamics/deatil/view.dart +++ b/lib/pages/dynamics/deatil/view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/skeleton/video_reply.dart'; import 'package:pilipala/common/widgets/http_error.dart'; @@ -9,7 +10,10 @@ import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/pages/dynamics/deatil/index.dart'; import 'package:pilipala/pages/dynamics/widgets/author_panel.dart'; import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; +import 'package:pilipala/pages/video/detail/replyNew/index.dart'; import 'package:pilipala/pages/video/detail/replyReply/index.dart'; +import 'package:pilipala/utils/feed_back.dart'; +import 'package:pilipala/utils/id_utils.dart'; import '../widgets/dynamic_panel.dart'; @@ -21,15 +25,18 @@ class DynamicDetailPage extends StatefulWidget { State createState() => _DynamicDetailPageState(); } -class _DynamicDetailPageState extends State { - late DynamicDetailController? _dynamicDetailController; +class _DynamicDetailPageState extends State + with TickerProviderStateMixin { + late DynamicDetailController _dynamicDetailController; + late AnimationController fabAnimationCtr; Future? _futureBuilderFuture; late StreamController titleStreamC; // appBar title - final ScrollController scrollController = ScrollController(); + late ScrollController scrollController; bool _visibleTitle = false; String? action; // 回复类型 late int type; + bool _isFabVisible = true; @override void initState() { @@ -38,39 +45,42 @@ class _DynamicDetailPageState extends State { // floor 1原创 2转发 if (Get.arguments['floor'] == 1) { oid = int.parse(Get.arguments['item'].basic!['comment_id_str']); + print(oid); } else { - oid = Get.arguments['item'].modules.moduleDynamic.major.draw.id; + try { + String type = Get.arguments['item'].modules.moduleDynamic.major.type; + + /// TODO + if (type == 'MAJOR_TYPE_OPUS') { + } else { + oid = Get.arguments['item'].modules.moduleDynamic.major.draw.id; + } + } catch (_) {} } - int commentType = Get.arguments['item'].basic!['comment_type'] ?? 11; + int commentType = 11; + try { + commentType = Get.arguments['item'].basic!['comment_type']; + } catch (_) {} type = (commentType == 0) ? 11 : commentType; action = Get.arguments.containsKey('action') ? Get.arguments['action'] : null; - _dynamicDetailController = Get.put(DynamicDetailController(oid, type)); - _futureBuilderFuture = _dynamicDetailController!.queryReplyList(); + _dynamicDetailController = + Get.put(DynamicDetailController(oid, type), tag: oid.toString()); + _futureBuilderFuture = _dynamicDetailController.queryReplyList(); titleStreamC = StreamController(); - scrollController.addListener(_listen); if (action == 'comment') { _visibleTitle = true; titleStreamC.add(true); } - } - void _listen() async { - if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent - 300) { - EasyThrottle.throttle('replylist', const Duration(seconds: 2), () { - _dynamicDetailController!.queryReplyList(reqType: 'onLoad'); - }); - } - - if (scrollController.offset > 55 && !_visibleTitle) { - _visibleTitle = true; - titleStreamC.add(true); - } else if (scrollController.offset <= 55 && _visibleTitle) { - _visibleTitle = false; - titleStreamC.add(false); - } + fabAnimationCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + fabAnimationCtr.forward(); + // 滚动事件监听 + scrollListener(); } void replyReply(replyItem) { @@ -97,9 +107,58 @@ class _DynamicDetailPageState extends State { ); } + void scrollListener() { + scrollController = _dynamicDetailController.scrollController; + scrollController.addListener( + () { + // 分页加载 + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + EasyThrottle.throttle('replylist', const Duration(seconds: 2), () { + _dynamicDetailController.queryReplyList(reqType: 'onLoad'); + }); + } + + // 标题 + if (scrollController.offset > 55 && !_visibleTitle) { + _visibleTitle = true; + titleStreamC.add(true); + } else if (scrollController.offset <= 55 && _visibleTitle) { + _visibleTitle = false; + titleStreamC.add(false); + } + + // fab按钮 + final ScrollDirection direction = + scrollController.position.userScrollDirection; + if (direction == ScrollDirection.forward) { + _showFab(); + } else if (direction == ScrollDirection.reverse) { + _hideFab(); + } + }, + ); + } + + void _showFab() { + if (!_isFabVisible) { + _isFabVisible = true; + fabAnimationCtr.forward(); + } + } + + void _hideFab() { + if (_isFabVisible) { + _isFabVisible = false; + fabAnimationCtr.reverse(); + } + } + @override void dispose() { scrollController.removeListener(() {}); + fabAnimationCtr.dispose(); + scrollController.dispose(); super.dispose(); } @@ -118,7 +177,7 @@ class _DynamicDetailPageState extends State { return AnimatedOpacity( opacity: snapshot.data ? 1 : 0, duration: const Duration(milliseconds: 300), - child: author(_dynamicDetailController!.item, context), + child: AuthorPanel(item: _dynamicDetailController.item), ); }, ), @@ -126,155 +185,206 @@ class _DynamicDetailPageState extends State { ), body: RefreshIndicator( onRefresh: () async { - await _dynamicDetailController!.queryReplyList(); + await _dynamicDetailController.queryReplyList(); }, - child: CustomScrollView( - controller: scrollController, - slivers: [ - if (action != 'comment') - SliverToBoxAdapter( - child: DynamicPanel( - item: _dynamicDetailController!.item, - source: 'detail', - ), - ), - SliverPersistentHeader( - delegate: _MySliverPersistentHeaderDelegate( - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border( - top: BorderSide( - width: 0.6, - color: Theme.of(context).dividerColor.withOpacity(0.05), - ), + child: Stack( + children: [ + CustomScrollView( + controller: scrollController, + slivers: [ + if (action != 'comment') + SliverToBoxAdapter( + child: DynamicPanel( + item: _dynamicDetailController.item, + source: 'detail', ), ), - height: 45, - padding: const EdgeInsets.only(left: 12, right: 6), - child: Row( - children: [ - Obx( - () => AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: - (Widget child, Animation animation) { - return ScaleTransition( - scale: animation, child: child); - }, - child: Text( - '${_dynamicDetailController!.acount.value}', - key: ValueKey( - _dynamicDetailController!.acount.value), + SliverPersistentHeader( + delegate: _MySliverPersistentHeaderDelegate( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + width: 0.6, + color: Theme.of(context) + .dividerColor + .withOpacity(0.05), ), ), ), - const Text('条回复'), - const Spacer(), - SizedBox( - height: 35, - child: TextButton.icon( - onPressed: () => - _dynamicDetailController!.queryBySort(), - icon: const Icon(Icons.sort, size: 16), - label: Obx(() => Text( - _dynamicDetailController!.sortTypeLabel.value, - style: const TextStyle(fontSize: 13), - )), - ), - ) - ], - ), - ), - ), - pinned: true, - ), - FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data as Map; - if (snapshot.data['status']) { - // 请求成功 - return Obx( - () => _dynamicDetailController!.replyList.isEmpty && - _dynamicDetailController!.isLoadingMore - ? SliverList( - delegate: - SliverChildBuilderDelegate((context, index) { - return const VideoReplySkeleton(); - }, childCount: 8), - ) - : SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index == - _dynamicDetailController! - .replyList.length) { - return Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context) - .padding - .bottom), - height: MediaQuery.of(context) - .padding - .bottom + - 100, - child: Center( - child: Obx( - () => Text( - _dynamicDetailController! - .noMore.value, - style: TextStyle( - fontSize: 12, - color: Theme.of(context) - .colorScheme - .outline, - ), - ), - ), - ), - ); - } else { - return ReplyItem( - replyItem: _dynamicDetailController! - .replyList[index], - showReplyRow: true, - replyLevel: '1', - replyReply: (replyItem) => - replyReply(replyItem), - replyType: ReplyType.values[type], - addReply: (replyItem) { - _dynamicDetailController! - .replyList[index].replies! - .add(replyItem); - }, - ); - } - }, - childCount: - _dynamicDetailController!.replyList.length + - 1, + height: 45, + padding: const EdgeInsets.only(left: 12, right: 6), + child: Row( + children: [ + Obx( + () => AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: + (Widget child, Animation animation) { + return ScaleTransition( + scale: animation, child: child); + }, + child: Text( + '${_dynamicDetailController.acount.value}', + key: ValueKey( + _dynamicDetailController.acount.value), ), ), + ), + const Text('条回复'), + const Spacer(), + SizedBox( + height: 35, + child: TextButton.icon( + onPressed: () => + _dynamicDetailController.queryBySort(), + icon: const Icon(Icons.sort, size: 16), + label: Obx(() => Text( + _dynamicDetailController + .sortTypeLabel.value, + style: const TextStyle(fontSize: 13), + )), + ), + ) + ], + ), + ), + ), + pinned: true, + ), + FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (snapshot.data['status']) { + // 请求成功 + return Obx( + () => _dynamicDetailController.replyList.isEmpty && + _dynamicDetailController.isLoadingMore + ? SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return const VideoReplySkeleton(); + }, childCount: 8), + ) + : SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == + _dynamicDetailController + .replyList.length) { + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .padding + .bottom), + height: MediaQuery.of(context) + .padding + .bottom + + 100, + child: Center( + child: Obx( + () => Text( + _dynamicDetailController + .noMore.value, + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .outline, + ), + ), + ), + ), + ); + } else { + return ReplyItem( + replyItem: _dynamicDetailController + .replyList[index], + showReplyRow: true, + replyLevel: '1', + replyReply: (replyItem) => + replyReply(replyItem), + replyType: ReplyType.values[type], + addReply: (replyItem) { + _dynamicDetailController + .replyList[index].replies! + .add(replyItem); + }, + ); + } + }, + childCount: _dynamicDetailController + .replyList.length + + 1, + ), + ), + ); + } else { + // 请求错误 + return HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ); + } + } else { + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoReplySkeleton(); + }, childCount: 8), + ); + } + }, + ) + ], + ), + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 14, + right: 14, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 2), + end: const Offset(0, 0), + ).animate(CurvedAnimation( + parent: fabAnimationCtr, + curve: Curves.easeInOut, + )), + child: FloatingActionButton( + heroTag: null, + onPressed: () { + feedBack(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return VideoReplyNewDialog( + oid: _dynamicDetailController.oid ?? + IdUtils.bv2av(Get.parameters['bvid']!), + root: 0, + parent: 0, + replyType: ReplyType.values[type], + ); + }, + ).then( + (value) => { + // 完成评论,数据添加 + if (value != null && value['data'] != null) + { + _dynamicDetailController.replyList + .add(value['data']), + _dynamicDetailController.acount.value++ + } + }, ); - } else { - // 请求错误 - return HttpError( - errMsg: data['msg'], - fn: () => setState(() {}), - ); - } - } else { - // 骨架屏 - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return const VideoReplySkeleton(); - }, childCount: 8), - ); - } - }, - ) + }, + tooltip: '评论动态', + child: const Icon(Icons.reply), + ), + ), + ), ], ), ), diff --git a/lib/pages/dynamics/view.dart b/lib/pages/dynamics/view.dart index d7d40021..cad4bbd7 100644 --- a/lib/pages/dynamics/view.dart +++ b/lib/pages/dynamics/view.dart @@ -212,6 +212,9 @@ class _DynamicsPageState extends State future: _futureBuilderFutureUp, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } Map data = snapshot.data; if (data['status']) { return Obx(() => UpPanel(_dynamicsController.upData.value)); @@ -232,6 +235,9 @@ class _DynamicsPageState extends State future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } Map data = snapshot.data; if (data['status']) { List list = diff --git a/lib/pages/dynamics/widgets/additional_panel.dart b/lib/pages/dynamics/widgets/additional_panel.dart index e283fcf9..fa11f217 100644 --- a/lib/pages/dynamics/widgets/additional_panel.dart +++ b/lib/pages/dynamics/widgets/additional_panel.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/http/search.dart'; /// TODO 点击跳转 Widget addWidget(item, context, type, {floor = 1}) { @@ -19,8 +22,27 @@ Widget addWidget(item, context, type, {floor = 1}) { : Theme.of(context).colorScheme.background; switch (type) { case 'ADDITIONAL_TYPE_UGC': + // 转发的投稿 return InkWell( - onTap: () {}, + onTap: () async { + String text = dynamicProperty[type].jumpUrl; + RegExp bvRegex = RegExp(r'BV[0-9A-Za-z]{10}', caseSensitive: false); + Iterable matches = bvRegex.allMatches(text); + if (matches.isNotEmpty) { + Match match = matches.first; + String bvid = match.group(0)!; + String cover = dynamicProperty[type].cover; + try { + int cid = await SearchHttp.ab2c(bvid: bvid); + Get.toNamed('/video?bvid=$bvid&cid=$cid', + arguments: {'pic': cover, 'heroTag': bvid}); + } catch (err) { + SmartDialog.showToast(err.toString()); + } + } else { + print("No match found."); + } + }, child: Container( padding: const EdgeInsets.only(left: 12, top: 8, right: 12, bottom: 8), @@ -61,101 +83,111 @@ Widget addWidget(item, context, type, {floor = 1}) { ); case 'ADDITIONAL_TYPE_RESERVE': return dynamicProperty[type].state != -1 - ? Padding( - padding: const EdgeInsets.only(top: 8), - child: InkWell( - onTap: () {}, - child: Container( - width: double.infinity, - padding: const EdgeInsets.only( - left: 12, top: 10, right: 12, bottom: 10), - color: bgColor, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dynamicProperty[type].title, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ? dynamicProperty[type].title != null + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.only( + left: 12, top: 10, right: 12, bottom: 10), + color: bgColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dynamicProperty[type].title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + Text.rich( + TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: Theme.of(context) + .textTheme + .labelMedium! + .fontSize), + children: [ + if (dynamicProperty[type].desc1 != null) + TextSpan( + text: + dynamicProperty[type].desc1['text']), + const TextSpan(text: ' '), + if (dynamicProperty[type].desc2 != null) + TextSpan( + text: + dynamicProperty[type].desc2['text']), + ], + ), + ) + ], ), - const SizedBox(height: 1), - Text.rich( - TextSpan( - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: Theme.of(context) - .textTheme - .labelMedium! - .fontSize), - children: [ - TextSpan(text: dynamicProperty[type].desc1['text']), - const TextSpan(text: ' '), - TextSpan(text: dynamicProperty[type].desc2['text']), - ], - ), - ) - ], - ), - // TextButton(onPressed: () {}, child: Text('123')) - ), - ), - ) - : const SizedBox(); - case 'ADDITIONAL_TYPE_GOODS': - return Padding( - padding: const EdgeInsets.only(top: 6), - child: InkWell( - onTap: () {}, - child: Container( - padding: - const EdgeInsets.only(left: 12, top: 8, right: 12, bottom: 8), - decoration: BoxDecoration( - color: bgColor, - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - child: Row( - children: [ - NetworkImgLayer( - width: 75, - height: 75, - src: dynamicProperty[type].items.first.cover, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - dynamicProperty[type].items.first.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - dynamicProperty[type].items.first.brief, - maxLines: 1, - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: Theme.of(context) - .textTheme - .labelMedium! - .fontSize, - ), - ), - const SizedBox(height: 2), - Text( - dynamicProperty[type].items.first.price, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], + // TextButton(onPressed: () {}, child: Text('123')) ), ), - ], - ), - ), - )); + ) + : const SizedBox() + : const SizedBox(); + case 'ADDITIONAL_TYPE_GOODS': + // 商品 + return const SizedBox(); + // return Padding( + // padding: const EdgeInsets.only(top: 6), + // child: InkWell( + // onTap: () {}, + // child: Container( + // padding: + // const EdgeInsets.only(left: 12, top: 8, right: 12, bottom: 8), + // decoration: BoxDecoration( + // color: bgColor, + // borderRadius: const BorderRadius.all(Radius.circular(6)), + // ), + // child: Row( + // children: [ + // NetworkImgLayer( + // width: 75, + // height: 75, + // src: dynamicProperty[type].items.first.cover, + // ), + // const SizedBox(width: 10), + // Expanded( + // child: Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // mainAxisAlignment: MainAxisAlignment.start, + // children: [ + // Text( + // dynamicProperty[type].items.first.name, + // maxLines: 1, + // overflow: TextOverflow.ellipsis, + // ), + // Text( + // dynamicProperty[type].items.first.brief, + // maxLines: 1, + // style: TextStyle( + // color: Theme.of(context).colorScheme.outline, + // fontSize: Theme.of(context) + // .textTheme + // .labelMedium! + // .fontSize, + // ), + // ), + // const SizedBox(height: 2), + // Text( + // dynamicProperty[type].items.first.price, + // style: TextStyle( + // color: Theme.of(context).colorScheme.primary, + // ), + // ), + // ], + // ), + // ), + // ], + // ), + // ), + // ),); case 'ADDITIONAL_TYPE_MATCH': return const SizedBox(); case 'ADDITIONAL_TYPE_COMMON': diff --git a/lib/pages/dynamics/widgets/author_panel.dart b/lib/pages/dynamics/widgets/author_panel.dart index 67a21371..b6ea5eb9 100644 --- a/lib/pages/dynamics/widgets/author_panel.dart +++ b/lib/pages/dynamics/widgets/author_panel.dart @@ -1,65 +1,163 @@ import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/http/user.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/utils.dart'; -Widget author(item, context) { - String heroTag = Utils.makeHeroTag(item.modules.moduleAuthor.mid); - return Row( - children: [ - GestureDetector( - onTap: () { - feedBack(); - Get.toNamed( - '/member?mid=${item.modules.moduleAuthor.mid}', - arguments: { - 'face': item.modules.moduleAuthor.face, - 'heroTag': heroTag - }, - ); - }, - child: Hero( - tag: heroTag, - child: NetworkImgLayer( - width: 40, - height: 40, - type: 'avatar', - src: item.modules.moduleAuthor.face, +class AuthorPanel extends StatelessWidget { + final dynamic item; + const AuthorPanel({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + String heroTag = Utils.makeHeroTag(item.modules.moduleAuthor.mid); + return Row( + children: [ + GestureDetector( + onTap: () { + // 番剧 + if (item.modules.moduleAuthor.type == 'AUTHOR_TYPE_PGC') { + return; + } + feedBack(); + Get.toNamed( + '/member?mid=${item.modules.moduleAuthor.mid}', + arguments: { + 'face': item.modules.moduleAuthor.face, + 'heroTag': heroTag + }, + ); + }, + child: Hero( + tag: heroTag, + child: NetworkImgLayer( + width: 40, + height: 40, + type: 'avatar', + src: item.modules.moduleAuthor.face, + ), ), ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.modules.moduleAuthor.name, - style: TextStyle( - color: item.modules.moduleAuthor!.vip != null && - item.modules.moduleAuthor!.vip['status'] > 0 - ? const Color.fromARGB(255, 251, 100, 163) - : Theme.of(context).colorScheme.onBackground, - fontSize: Theme.of(context).textTheme.titleSmall!.fontSize, + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.modules.moduleAuthor.name, + style: TextStyle( + color: item.modules.moduleAuthor!.vip != null && + item.modules.moduleAuthor!.vip['status'] > 0 + ? const Color.fromARGB(255, 251, 100, 163) + : Theme.of(context).colorScheme.onBackground, + fontSize: Theme.of(context).textTheme.titleSmall!.fontSize, + ), + ), + DefaultTextStyle.merge( + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + ), + child: Row( + children: [ + Text(item.modules.moduleAuthor.pubTime), + if (item.modules.moduleAuthor.pubTime != '' && + item.modules.moduleAuthor.pubAction != '') + const Text(' '), + Text(item.modules.moduleAuthor.pubAction), + ], + ), + ) + ], + ), + const Spacer(), + if (item.type == 'DYNAMIC_TYPE_AV') + SizedBox( + width: 32, + height: 32, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) { + return MorePanel(item: item); + }, + ); + }, + icon: const Icon(Icons.more_vert_outlined, size: 18), ), ), - DefaultTextStyle.merge( - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + ], + ); + } +} + +class MorePanel extends StatelessWidget { + final dynamic item; + const MorePanel({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + // clipBehavior: Clip.hardEdge, + child: Column( + mainAxisSize: MainAxisSize.min, + 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.outline, + borderRadius: const BorderRadius.all(Radius.circular(3))), + ), + ), ), - child: Row( - children: [ - Text(item.modules.moduleAuthor.pubTime), - if (item.modules.moduleAuthor.pubTime != '' && - item.modules.moduleAuthor.pubAction != '') - const Text(' '), - Text(item.modules.moduleAuthor.pubAction), - ], + ), + ListTile( + onTap: () async { + try { + String bvid = item.modules.moduleDynamic.major.archive.bvid; + var res = await UserHttp.toViewLater(bvid: bvid); + SmartDialog.showToast(res['msg']); + Get.back(); + } catch (err) { + SmartDialog.showToast('出错了:${err.toString()}'); + } + }, + minLeadingWidth: 0, + // dense: true, + leading: const Icon(Icons.watch_later_outlined, size: 19), + title: Text( + '稍后再看', + style: Theme.of(context).textTheme.titleSmall, ), - ) + ), + const Divider(thickness: 0.1, height: 1), + ListTile( + onTap: () => Get.back(), + minLeadingWidth: 0, + dense: true, + title: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + textAlign: TextAlign.center, + ), + ), ], ), - ], - ); + ); + } } diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 34324d2e..680d21a2 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,40 +1,183 @@ // 内容 import 'package:flutter/material.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/dynamics/result.dart'; +import 'package:pilipala/pages/preview/index.dart'; import 'rich_node_panel.dart'; -Widget content(item, context, source) { - TextStyle authorStyle = - TextStyle(color: Theme.of(context).colorScheme.primary); - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (item.modules.moduleDynamic.topic != null) ...[ - GestureDetector( - child: Text( - '#${item.modules.moduleDynamic.topic.name}', - style: authorStyle, - ), - ), - ], - IgnorePointer( - // 禁用SelectableRegion的触摸交互功能 - ignoring: source == 'detail' ? false : true, - child: SelectableRegion( - magnifierConfiguration: const TextMagnifierConfiguration(), - focusNode: FocusNode(), - selectionControls: MaterialTextSelectionControls(), - child: Text.rich( - richNode(item, context), - maxLines: source == 'detail' ? 999 : 3, - overflow: TextOverflow.ellipsis, - ), +// ignore: must_be_immutable +class Content extends StatefulWidget { + dynamic item; + String? source; + Content({ + super.key, + this.item, + this.source, + }); + + @override + State createState() => _ContentState(); +} + +class _ContentState extends State { + late bool hasPics; + List pics = []; + + @override + void initState() { + super.initState(); + hasPics = widget.item.modules.moduleDynamic.major != null && + widget.item.modules.moduleDynamic.major.opus != null && + widget.item.modules.moduleDynamic.major.opus.pics.isNotEmpty; + if (hasPics) { + pics = widget.item.modules.moduleDynamic.major.opus.pics; + } + } + + InlineSpan picsNodes() { + List spanChilds = []; + int len = pics.length; + List picList = []; + + if (len == 1) { + OpusPicsModel pictureItem = pics.first; + picList.add(pictureItem.url!); + spanChilds.add(const TextSpan(text: '\n')); + spanChilds.add( + WidgetSpan( + child: LayoutBuilder( + builder: (context, BoxConstraints box) { + return GestureDetector( + onTap: () { + showDialog( + useSafeArea: false, + context: context, + builder: (context) { + return ImagePreview(initialPage: 0, imgList: picList); + }, + ); + }, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: NetworkImgLayer( + src: pictureItem.url, + width: box.maxWidth / 2, + height: box.maxWidth * + 0.5 * + (pictureItem.height != null && pictureItem.width != null + ? pictureItem.height! / pictureItem.width! + : 1), + ), + ), + ); + }, ), ), - ], - ), - ); + ); + } + if (len > 1) { + List list = []; + for (var i = 0; i < len; i++) { + picList.add(pics[i].url!); + list.add( + LayoutBuilder( + builder: (context, BoxConstraints box) { + return GestureDetector( + onTap: () { + showDialog( + useSafeArea: false, + context: context, + builder: (context) { + return ImagePreview(initialPage: i, imgList: picList); + }, + ); + }, + child: NetworkImgLayer( + src: pics[i].url, + width: box.maxWidth, + height: box.maxWidth, + ), + ); + }, + ), + ); + } + spanChilds.add( + WidgetSpan( + child: LayoutBuilder( + builder: (context, BoxConstraints box) { + double maxWidth = box.maxWidth; + double crossCount = len < 3 ? 2 : 3; + double height = maxWidth / + crossCount * + (len % crossCount == 0 + ? len ~/ crossCount + : len ~/ crossCount + 1) + + 6; + return Container( + padding: const EdgeInsets.only(top: 6), + height: height, + child: GridView.count( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: crossCount.toInt(), + mainAxisSpacing: 4.0, + crossAxisSpacing: 4.0, + childAspectRatio: 1, + children: list, + ), + ); + }, + ), + ), + ); + } + return TextSpan( + children: spanChilds, + ); + } + + @override + Widget build(BuildContext context) { + TextStyle authorStyle = + TextStyle(color: Theme.of(context).colorScheme.primary); + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.item.modules.moduleDynamic.topic != null) ...[ + GestureDetector( + child: Text( + '#${widget.item.modules.moduleDynamic.topic.name}', + style: authorStyle, + ), + ), + ], + IgnorePointer( + // 禁用SelectableRegion的触摸交互功能 + ignoring: widget.source == 'detail' ? false : true, + child: SelectableRegion( + magnifierConfiguration: const TextMagnifierConfiguration(), + focusNode: FocusNode(), + selectionControls: MaterialTextSelectionControls(), + child: Text.rich( + /// fix 默认20px高度 + style: const TextStyle(height: 0), + richNode(widget.item, context), + maxLines: widget.source == 'detail' ? 999 : 3, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (hasPics) ...[ + Text.rich(picsNodes()), + ] + ], + ), + ); + } } diff --git a/lib/pages/dynamics/widgets/dynamic_panel.dart b/lib/pages/dynamics/widgets/dynamic_panel.dart index ef0bc8cc..c85cad45 100644 --- a/lib/pages/dynamics/widgets/dynamic_panel.dart +++ b/lib/pages/dynamics/widgets/dynamic_panel.dart @@ -39,10 +39,11 @@ class DynamicPanel extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: author(item, context), + child: AuthorPanel(item: item), ), - if (item!.modules!.moduleDynamic!.desc != null) - content(item, context, source), + if (item!.modules!.moduleDynamic!.desc != null || + item!.modules!.moduleDynamic!.major != null) + Content(item: item, source: source), forWard(item, context, _dynamicsController, source), const SizedBox(height: 2), if (source == null) ActionPanel(item: item), diff --git a/lib/pages/dynamics/widgets/forward_panel.dart b/lib/pages/dynamics/widgets/forward_panel.dart index 55972e37..5a8d9f17 100644 --- a/lib/pages/dynamics/widgets/forward_panel.dart +++ b/lib/pages/dynamics/widgets/forward_panel.dart @@ -44,19 +44,21 @@ Widget forWard(item, context, ctr, source, {floor = 1}) { ], ), const SizedBox(height: 2), - if (item.modules.moduleDynamic.topic != null) ...[ - Padding( - padding: floor == 2 - ? EdgeInsets.zero - : const EdgeInsets.only(left: 12, right: 12), - child: GestureDetector( - child: Text( - '#${item.modules.moduleDynamic.topic.name}', - style: authorStyle, - ), - ), - ), - ], + + /// fix #话题跟content重复 + // if (item.modules.moduleDynamic.topic != null) ...[ + // Padding( + // padding: floor == 2 + // ? EdgeInsets.zero + // : const EdgeInsets.only(left: 12, right: 12), + // child: GestureDetector( + // child: Text( + // '#${item.modules.moduleDynamic.topic.name}', + // style: authorStyle, + // ), + // ), + // ), + // ], Text.rich( richNode(item, context), // 被转发状态(floor=2) 隐藏 @@ -71,6 +73,8 @@ Widget forWard(item, context, ctr, source, {floor = 1}) { : const EdgeInsets.only(left: 12, right: 12), child: picWidget(item, context), ), + + /// 附加内容 商品信息、直播预约等等 if (item.modules.moduleDynamic.additional != null) addWidget( item, @@ -133,7 +137,12 @@ Widget forWard(item, context, ctr, source, {floor = 1}) { ], ), const SizedBox(height: 8), - Text(item.modules.moduleDynamic.desc.text) + Text.rich( + richNode(item, context), + // 被转发状态(floor=2) 隐藏 + maxLines: source == 'detail' && floor != 2 ? 999 : 4, + overflow: TextOverflow.ellipsis, + ), ], ) : item.modules.moduleDynamic.additional != null diff --git a/lib/pages/dynamics/widgets/pic_panel.dart b/lib/pages/dynamics/widgets/pic_panel.dart index 9ee8be53..25b22c21 100644 --- a/lib/pages/dynamics/widgets/pic_panel.dart +++ b/lib/pages/dynamics/widgets/pic_panel.dart @@ -1,20 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:pilipala/common/constants.dart'; import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/pages/preview/index.dart'; Widget picWidget(item, context) { String type = item.modules.moduleDynamic.major.type; List pictures = []; if (type == 'MAJOR_TYPE_OPUS') { - pictures = item.modules.moduleDynamic.major.opus.pics; + /// fix 图片跟rich_node_panel重复 + // pictures = item.modules.moduleDynamic.major.opus.pics; + return const SizedBox(); } if (type == 'MAJOR_TYPE_DRAW') { pictures = item.modules.moduleDynamic.major.draw.items; } int len = pictures.length; - List picList = []; + List picList = []; List list = []; for (var i = 0; i < len; i++) { picList.add(pictures[i].src ?? pictures[i].url); @@ -23,11 +25,14 @@ Widget picWidget(item, context) { builder: (context, BoxConstraints box) { return GestureDetector( onTap: () { - Get.toNamed('/preview', - arguments: {'initialPage': i, 'imgList': picList}); + showDialog( + useSafeArea: false, + context: context, + builder: (context) { + return ImagePreview(initialPage: i, imgList: picList); + }, + ); }, - // child: Hero( - // tag: pictures[i].src ?? pictures[i].url, child: NetworkImgLayer( src: pictures[i].src ?? pictures[i].url, width: box.maxWidth, diff --git a/lib/pages/dynamics/widgets/rich_node_panel.dart b/lib/pages/dynamics/widgets/rich_node_panel.dart index 78d5aaba..a66772a4 100644 --- a/lib/pages/dynamics/widgets/rich_node_panel.dart +++ b/lib/pages/dynamics/widgets/rich_node_panel.dart @@ -1,175 +1,324 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/dynamics/result.dart'; +import 'package:pilipala/pages/preview/index.dart'; // 富文本 InlineSpan richNode(item, context) { - TextStyle authorStyle = - TextStyle(color: Theme.of(context).colorScheme.primary); - List spanChilds = []; - for (var i in item.modules.moduleDynamic.desc.richTextNodes) { - if (i.type == 'RICH_TEXT_NODE_TYPE_TEXT') { - spanChilds.add( - TextSpan(text: i.origText, style: const TextStyle(height: 1.65))); + final spacer = _VerticalSpaceSpan(0.0); + try { + TextStyle authorStyle = + TextStyle(color: Theme.of(context).colorScheme.primary); + List spanChilds = []; + String contentType = 'desc'; + + dynamic richTextNodes; + if (item.modules.moduleDynamic.desc != null) { + richTextNodes = item.modules.moduleDynamic.desc.richTextNodes; + } else if (item.modules.moduleDynamic.major != null) { + contentType = 'major'; + // 动态页面 richTextNodes 层级可能与主页动态层级不同 + richTextNodes = + item.modules.moduleDynamic.major.opus.summary.richTextNodes; } - // @用户 - if (i.type == 'RICH_TEXT_NODE_TYPE_AT') { - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: () => Get.toNamed('/member?mid=${i.rid}', - arguments: {'face': null}), + if (richTextNodes == null || richTextNodes.isEmpty) { + return spacer; + } else { + for (var i in richTextNodes) { + /// fix 渲染专栏时内容会重复 + // if (item.modules.moduleDynamic.major.opus.title == null && + // i.type == 'RICH_TEXT_NODE_TYPE_TEXT') { + if (i.type == 'RICH_TEXT_NODE_TYPE_TEXT') { + spanChilds.add( + TextSpan(text: i.origText, style: const TextStyle(height: 1.65))); + } + // @用户 + if (i.type == 'RICH_TEXT_NODE_TYPE_AT') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => Get.toNamed('/member?mid=${i.rid}', + arguments: {'face': null}), + child: Text( + ' ${i.text}', + style: authorStyle, + ), + ), + ], + ), + ), + ); + } + // 话题 + if (i.type == 'RICH_TEXT_NODE_TYPE_TOPIC') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () {}, child: Text( - ' ${i.text}', + '${i.origText}', style: authorStyle, ), ), - ], - ), - ), - ); - } - // 话题 - if (i.type == 'RICH_TEXT_NODE_TYPE_TOPIC') { - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: () {}, - child: Text( - '${i.origText}', - style: authorStyle, ), - ), - ), - ); - } - // 网页链接 - if (i.type == 'RICH_TEXT_NODE_TYPE_WEB') { - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Icon( - Icons.link, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: () { - Get.toNamed( - '/webview', - parameters: {'url': i.origText, 'type': 'url', 'pageTitle': ''}, - ); - }, - child: Text( - i.text, - style: authorStyle, + ); + } + // 网页链接 + if (i.type == 'RICH_TEXT_NODE_TYPE_WEB') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.link, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), ), - ), - ), - ); - } - // 投票 - if (i.type == 'RICH_TEXT_NODE_TYPE_VOTE') { - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: () { - String dynamicId = item.basic['comment_id_str']; - Get.toNamed( - '/webview', - parameters: { - 'url': - 'https://t.bilibili.com/vote/h5/index/#/result?vote_id=${i.rid}&dynamic_id=$dynamicId&isWeb=1', - 'type': 'vote', - 'pageTitle': '投票' + ); + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () { + Get.toNamed( + '/webview', + parameters: { + 'url': i.origText, + 'type': 'url', + 'pageTitle': '' + }, + ); }, - ); - }, - child: Text( - '投票:${i.text}', - style: authorStyle, + child: Text( + i.text, + style: authorStyle, + ), + ), ), - ), - ), - ); - } - // 表情 - if (i.type == 'RICH_TEXT_NODE_TYPE_EMOJI') { - spanChilds.add( - WidgetSpan( - child: NetworkImgLayer( - src: i.emoji.iconUrl, - type: 'emote', - width: i.emoji.size * 20, - height: i.emoji.size * 20, - ), - ), - ); - } - // 抽奖 - if (i.type == 'RICH_TEXT_NODE_TYPE_LOTTERY') { - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Icon( - Icons.redeem_rounded, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: () {}, - child: Text( - '${i.origText} ', - style: authorStyle, + ); + } + // 投票 + if (i.type == 'RICH_TEXT_NODE_TYPE_VOTE') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () { + try { + String dynamicId = item.basic['comment_id_str']; + Get.toNamed( + '/webview', + parameters: { + 'url': + 'https://t.bilibili.com/vote/h5/index/#/result?vote_id=${i.rid}&dynamic_id=$dynamicId&isWeb=1', + 'type': 'vote', + 'pageTitle': '投票' + }, + ); + } catch (_) {} + }, + child: Text( + '投票:${i.text}', + style: authorStyle, + ), + ), ), - ), - ), - ); - } + ); + } + // 表情 + if (i.type == 'RICH_TEXT_NODE_TYPE_EMOJI') { + spanChilds.add( + WidgetSpan( + child: NetworkImgLayer( + src: i.emoji.iconUrl, + type: 'emote', + width: i.emoji.size * 20, + height: i.emoji.size * 20, + ), + ), + ); + } + // 抽奖 + if (i.type == 'RICH_TEXT_NODE_TYPE_LOTTERY') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.redeem_rounded, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () {}, + child: Text( + '${i.origText} ', + style: authorStyle, + ), + ), + ), + ); + } - /// TODO 商品 - if (i.type == 'RICH_TEXT_NODE_TYPE_GOODS') { - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Icon( - Icons.shopping_bag_outlined, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - spanChilds.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: () {}, - child: Text( - '${i.text} ', - style: authorStyle, + /// TODO 商品 + if (i.type == 'RICH_TEXT_NODE_TYPE_GOODS') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.shopping_bag_outlined, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), ), - ), - ), + ); + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () {}, + child: Text( + '${i.text} ', + style: authorStyle, + ), + ), + ), + ); + } + } + // if (contentType == 'major' && + // item.modules.moduleDynamic.major.opus.pics.isNotEmpty) { + // // 图片可能跟其他widget重复渲染 + // List pics = item.modules.moduleDynamic.major.opus.pics; + // int len = pics.length; + // List picList = []; + + // if (len == 1) { + // OpusPicsModel pictureItem = pics.first; + // picList.add(pictureItem.url!); + // spanChilds.add(const TextSpan(text: '\n')); + // spanChilds.add( + // WidgetSpan( + // child: LayoutBuilder( + // builder: (context, BoxConstraints box) { + // return GestureDetector( + // onTap: () { + // showDialog( + // useSafeArea: false, + // context: context, + // builder: (context) { + // return ImagePreview(initialPage: 0, imgList: picList); + // }, + // ); + // }, + // child: Padding( + // padding: const EdgeInsets.only(top: 4), + // child: NetworkImgLayer( + // src: pictureItem.url, + // width: box.maxWidth / 2, + // height: box.maxWidth * + // 0.5 * + // (pictureItem.height != null && + // pictureItem.width != null + // ? pictureItem.height! / pictureItem.width! + // : 1), + // ), + // ), + // ); + // }, + // ), + // ), + // ); + // } + // if (len > 1) { + // List list = []; + // for (var i = 0; i < len; i++) { + // picList.add(pics[i].url!); + // list.add( + // LayoutBuilder( + // builder: (context, BoxConstraints box) { + // return GestureDetector( + // onTap: () { + // showDialog( + // useSafeArea: false, + // context: context, + // builder: (context) { + // return ImagePreview(initialPage: i, imgList: picList); + // }, + // ); + // }, + // child: NetworkImgLayer( + // src: pics[i].url, + // width: box.maxWidth, + // height: box.maxWidth, + // ), + // ); + // }, + // ), + // ); + // } + // spanChilds.add( + // WidgetSpan( + // child: LayoutBuilder( + // builder: (context, BoxConstraints box) { + // double maxWidth = box.maxWidth; + // double crossCount = len < 3 ? 2 : 3; + // double height = maxWidth / + // crossCount * + // (len % crossCount == 0 + // ? len ~/ crossCount + // : len ~/ crossCount + 1) + + // 6; + // return Container( + // padding: const EdgeInsets.only(top: 6), + // height: height, + // child: GridView.count( + // padding: EdgeInsets.zero, + // physics: const NeverScrollableScrollPhysics(), + // crossAxisCount: crossCount.toInt(), + // mainAxisSpacing: 4.0, + // crossAxisSpacing: 4.0, + // childAspectRatio: 1, + // children: list, + // ), + // ); + // }, + // ), + // ), + // ); + // } + // spanChilds.add( + // WidgetSpan( + // child: NetworkImgLayer( + // src: pics.first.url, + // type: 'emote', + // width: 100, + // height: 200, + // ), + // ), + // ); + // } + return TextSpan( + children: spanChilds, ); } + } catch (err) { + print('❌rich_node_panel err: $err'); + return spacer; } - return TextSpan( - children: spanChilds, - ); +} + +class _VerticalSpaceSpan extends WidgetSpan { + _VerticalSpaceSpan(double height) + : super(child: SizedBox(height: height, width: double.infinity)); } diff --git a/lib/pages/dynamics/widgets/up_panel.dart b/lib/pages/dynamics/widgets/up_panel.dart index a12956a5..3db6c357 100644 --- a/lib/pages/dynamics/widgets/up_panel.dart +++ b/lib/pages/dynamics/widgets/up_panel.dart @@ -91,7 +91,10 @@ class _UpPanelState extends State { ), Material( child: InkWell( - onTap: () => {feedBack(), Get.toNamed('/follow')}, + onTap: () => { + feedBack(), + Get.toNamed('/follow?mid=${userInfo.mid}') + }, child: Container( height: 100, padding: const EdgeInsets.only(left: 10, right: 10), diff --git a/lib/pages/dynamics/widgets/video_panel.dart b/lib/pages/dynamics/widgets/video_panel.dart index 04c1ae19..32a6e21c 100644 --- a/lib/pages/dynamics/widgets/video_panel.dart +++ b/lib/pages/dynamics/widgets/video_panel.dart @@ -57,20 +57,21 @@ Widget videoSeasonWidget(item, context, type, {floor = 1}) { const SizedBox(height: 6), ], // const SizedBox(height: 4), - if (item.modules.moduleDynamic.topic != null) ...[ - Padding( - padding: floor == 2 - ? EdgeInsets.zero - : const EdgeInsets.only(left: 12, right: 12), - child: GestureDetector( - child: Text( - '#${item.modules.moduleDynamic.topic.name}', - style: authorStyle, - ), - ), - ), - const SizedBox(height: 6), - ], + /// fix #话题跟content重复 + // if (item.modules.moduleDynamic.topic != null) ...[ + // Padding( + // padding: floor == 2 + // ? EdgeInsets.zero + // : const EdgeInsets.only(left: 12, right: 12), + // child: GestureDetector( + // child: Text( + // '#${item.modules.moduleDynamic.topic.name}', + // style: authorStyle, + // ), + // ), + // ), + // const SizedBox(height: 6), + // ], if (floor == 2 && item.modules.moduleDynamic.desc != null) ...[ Text.rich(richNode(item, context)), const SizedBox(height: 6), diff --git a/lib/pages/fan/view.dart b/lib/pages/fan/view.dart index 6a0af3c6..3eea5093 100644 --- a/lib/pages/fan/view.dart +++ b/lib/pages/fan/view.dart @@ -16,13 +16,16 @@ class FansPage extends StatefulWidget { } class _FansPageState extends State { - final FansController _fansController = Get.put(FansController()); + late String mid; + late FansController _fansController; final ScrollController scrollController = ScrollController(); Future? _futureBuilderFuture; @override void initState() { super.initState(); + mid = Get.parameters['mid']!; + _fansController = Get.put(FansController(), tag: mid); _futureBuilderFuture = _fansController.queryFans('init'); scrollController.addListener( () async { diff --git a/lib/pages/fav/view.dart b/lib/pages/fav/view.dart index 1196efc9..b980914a 100644 --- a/lib/pages/fav/view.dart +++ b/lib/pages/fav/view.dart @@ -44,6 +44,14 @@ class _FavPageState extends State { '我的收藏', style: Theme.of(context).textTheme.titleMedium, ), + actions: [ + IconButton( + onPressed: () => Get.toNamed( + '/favSearch?searchType=1&mediaId=${_favController.favFolderData.value.list!.first.id}'), + icon: const Icon(Icons.search_outlined), + ), + const SizedBox(width: 6), + ], ), body: FutureBuilder( future: _futureBuilderFuture, diff --git a/lib/pages/favDetail/controller.dart b/lib/pages/favDetail/controller.dart index 8b772716..c2c63dd5 100644 --- a/lib/pages/favDetail/controller.dart +++ b/lib/pages/favDetail/controller.dart @@ -14,7 +14,7 @@ class FavDetailController extends GetxController { int currentPage = 1; bool isLoadingMore = false; RxMap favInfo = {}.obs; - RxList favList = [FavDetailItemData()].obs; + RxList favList = [].obs; RxString loadingText = '加载中...'.obs; int mediaCount = 0; @@ -61,15 +61,13 @@ class FavDetailController extends GetxController { aid: id, addIds: '', delIds: mediaId.toString()); if (result['status']) { if (result['data']['prompt']) { - List dataList = favDetailData.value.medias!; + List dataList = favList; for (var i in dataList) { if (i.id == id) { dataList.remove(i); break; } } - favDetailData.value.medias = dataList; - favDetailData.refresh(); SmartDialog.showToast('取消收藏'); } } diff --git a/lib/pages/favDetail/view.dart b/lib/pages/favDetail/view.dart index 426bfa8f..fedc85fd 100644 --- a/lib/pages/favDetail/view.dart +++ b/lib/pages/favDetail/view.dart @@ -92,13 +92,18 @@ class _FavDetailPageState extends State { ); }, ), - // actions: [ - // IconButton( - // onPressed: () {}, - // icon: const Icon(Icons.more_vert), - // ), - // const SizedBox(width: 4) - // ], + actions: [ + IconButton( + onPressed: () => Get.toNamed( + '/favSearch?searchType=0&mediaId=${Get.parameters['mediaId']!}'), + icon: const Icon(Icons.search_outlined), + ), + // IconButton( + // onPressed: () {}, + // icon: const Icon(Icons.more_vert), + // ), + const SizedBox(width: 6), + ], flexibleSpace: FlexibleSpaceBar( background: Container( decoration: BoxDecoration( @@ -168,7 +173,7 @@ class _FavDetailPageState extends State { padding: const EdgeInsets.only(top: 15, bottom: 8, left: 14), child: Obx( () => Text( - '共${_favDetailController.favInfo['media_count'] ?? '-'}条视频', + '共${_favDetailController.favList.length}条视频', style: TextStyle( fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, @@ -187,14 +192,20 @@ class _FavDetailPageState extends State { if (_favDetailController.item!.mediaCount == 0) { return const NoData(); } else { + List favList = _favDetailController.favList; return Obx( - () => SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return FavVideoCardH( - videoItem: _favDetailController.favList[index], - ); - }, childCount: _favDetailController.favList.length), - ), + () => favList.isEmpty + ? const SliverToBoxAdapter(child: SizedBox()) + : SliverList( + delegate: + SliverChildBuilderDelegate((context, index) { + return FavVideoCardH( + videoItem: favList[index], + callFn: () => _favDetailController + .onCancelFav(favList[index].id), + ); + }, childCount: favList.length), + ), ); } } else { diff --git a/lib/pages/favDetail/widget/fav_video_card.dart b/lib/pages/favDetail/widget/fav_video_card.dart index 61ac06f1..471f19bc 100644 --- a/lib/pages/favDetail/widget/fav_video_card.dart +++ b/lib/pages/favDetail/widget/fav_video_card.dart @@ -10,134 +10,109 @@ import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; -import '../controller.dart'; - // 收藏视频卡片 - 水平布局 class FavVideoCardH extends StatelessWidget { final dynamic videoItem; - final FavDetailController _favDetailController = - Get.put(FavDetailController()); + final Function? callFn; - FavVideoCardH({Key? key, required this.videoItem}) : super(key: key); + const FavVideoCardH({Key? key, required this.videoItem, this.callFn}) + : super(key: key); @override Widget build(BuildContext context) { int id = videoItem.id; String bvid = videoItem.bvid ?? IdUtils.av2bv(id); String heroTag = Utils.makeHeroTag(id); - return Dismissible( - movementDuration: const Duration(milliseconds: 300), - background: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer, - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.clear_all_rounded), - SizedBox(width: 6), - Text('取消收藏') - ], - )), - direction: DismissDirection.endToStart, - key: ValueKey(videoItem.id), - onDismissed: (DismissDirection direction) { - _favDetailController.onCancelFav(videoItem.id); - // widget.onDeleteNotice(); - }, - child: InkWell( - onTap: () async { - // int? seasonId; - String? epId; - if (videoItem.ogv != null && videoItem.ogv['type_name'] == '番剧') { - videoItem.cid = await SearchHttp.ab2c(bvid: bvid); - // seasonId = videoItem.ogv['season_id']; - epId = videoItem.epId; - } else if (videoItem.page == 0 || videoItem.page > 1) { - var result = await VideoHttp.videoIntro(bvid: bvid); - if (result['status']) { - epId = result['data'].epId; - } + return InkWell( + onTap: () async { + // int? seasonId; + String? epId; + if (videoItem.ogv != null && videoItem.ogv['type_name'] == '番剧') { + videoItem.cid = await SearchHttp.ab2c(bvid: bvid); + // seasonId = videoItem.ogv['season_id']; + epId = videoItem.epId; + } else if (videoItem.page == 0 || videoItem.page > 1) { + var result = await VideoHttp.videoIntro(bvid: bvid); + if (result['status']) { + epId = result['data'].epId; } + } - Map parameters = { - 'bvid': bvid, - 'cid': videoItem.cid.toString(), - 'epId': epId ?? '', - }; - // if (seasonId != null) { - // parameters['seasonId'] = seasonId.toString(); - // } - Get.toNamed('/video', parameters: parameters, arguments: { - 'videoItem': videoItem, - 'heroTag': heroTag, - 'videoType': - epId != null ? SearchType.media_bangumi : SearchType.video, - }); - }, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - StyleString.safeSpace, 5, StyleString.safeSpace, 5), - child: LayoutBuilder( - builder: (context, boxConstraints) { - double width = - (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; - return SizedBox( - height: width / StyleString.aspectRatio, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: StyleString.aspectRatio, - child: LayoutBuilder( - builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; - return Stack( - children: [ - Hero( - tag: heroTag, - child: NetworkImgLayer( - src: videoItem.pic, - width: maxWidth, - height: maxHeight, + Map parameters = { + 'bvid': bvid, + 'cid': videoItem.cid.toString(), + 'epId': epId ?? '', + }; + // if (seasonId != null) { + // parameters['seasonId'] = seasonId.toString(); + // } + Get.toNamed('/video', parameters: parameters, arguments: { + 'videoItem': videoItem, + 'heroTag': heroTag, + 'videoType': + epId != null ? SearchType.media_bangumi : SearchType.video, + }); + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + StyleString.safeSpace, 5, StyleString.safeSpace, 5), + child: LayoutBuilder( + builder: (context, boxConstraints) { + double width = + (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; + return SizedBox( + height: width / StyleString.aspectRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Stack( + children: [ + Hero( + tag: heroTag, + child: NetworkImgLayer( + src: videoItem.pic, + width: maxWidth, + height: maxHeight, + ), + ), + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 1, horizontal: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.black54.withOpacity(0.4)), + child: Text( + Utils.timeFormat(videoItem.duration!), + style: const TextStyle( + fontSize: 11, color: Colors.white), ), ), - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 1, horizontal: 6), - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(4), - color: - Colors.black54.withOpacity(0.4)), - child: Text( - Utils.timeFormat(videoItem.duration!), - style: const TextStyle( - fontSize: 11, color: Colors.white), - ), - ), - ) - ], - ); - }, - ), + ) + ], + ); + }, ), - VideoContent(videoItem: videoItem) - ], - ), - ); - }, - ), + ), + VideoContent(videoItem: videoItem, callFn: callFn) + ], + ), + ); + }, ), - ], - ), + ), + ], ), ); } @@ -145,7 +120,8 @@ class FavVideoCardH extends StatelessWidget { class VideoContent extends StatelessWidget { final dynamic videoItem; - const VideoContent({super.key, required this.videoItem}); + final Function? callFn; + const VideoContent({super.key, required this.videoItem, this.callFn}); @override Widget build(BuildContext context) { @@ -173,7 +149,6 @@ class VideoContent extends StatelessWidget { color: Theme.of(context).colorScheme.outline, ), ), - const SizedBox(height: 2), Row( children: [ StatView( @@ -181,7 +156,51 @@ class VideoContent extends StatelessWidget { view: videoItem.cntInfo['play'], ), const SizedBox(width: 8), - StatDanMu(theme: 'gray', danmu: videoItem.cntInfo['danmaku']) + StatDanMu(theme: 'gray', danmu: videoItem.cntInfo['danmaku']), + const Spacer(), + SizedBox( + width: 26, + height: 26, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + showDialog( + context: Get.context!, + builder: (context) { + return AlertDialog( + title: const Text('提示'), + content: const Text('要取消收藏吗?'), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + '取消', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline), + )), + TextButton( + onPressed: () async { + await callFn!(); + Get.back(); + }, + child: const Text('确定取消'), + ) + ], + ); + }, + ); + }, + icon: Icon( + Icons.clear_outlined, + color: Theme.of(context).colorScheme.outline, + size: 18, + ), + ), + ), ], ), ], diff --git a/lib/pages/fav_search/controller.dart b/lib/pages/fav_search/controller.dart new file mode 100644 index 00000000..642fea6b --- /dev/null +++ b/lib/pages/fav_search/controller.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/user.dart'; +import 'package:pilipala/models/user/fav_detail.dart'; + +class FavSearchController extends GetxController { + final ScrollController scrollController = ScrollController(); + Rx controller = TextEditingController().obs; + final FocusNode searchFocusNode = FocusNode(); + RxString searchKeyWord = ''.obs; // 搜索词 + String hintText = '请输入已收藏视频名称'; // 默认 + RxBool loadingStatus = false.obs; // 加载状态 + RxString loadingText = '加载中...'.obs; // 加载提示 + bool hasMore = false; + late int searchType; + late int mediaId; + + int currentPage = 1; // 当前页 + int count = 0; // 总数 + RxList favList = [].obs; + + @override + void onInit() { + super.onInit(); + searchType = int.parse(Get.parameters['searchType']!); + mediaId = int.parse(Get.parameters['mediaId']!); + } + + // 清空搜索 + void onClear() { + if (searchKeyWord.value.isNotEmpty && controller.value.text != '') { + controller.value.clear(); + searchKeyWord.value = ''; + } else { + Get.back(); + } + } + + void onChange(value) { + searchKeyWord.value = value; + } + + // 提交搜索内容 + void submit() { + loadingStatus.value = true; + currentPage = 1; + searchFav(); + } + + // 搜索收藏夹视频 + Future searchFav({type = 'init'}) async { + var res = await await UserHttp.userFavFolderDetail( + pn: currentPage, + ps: 20, + mediaId: mediaId, + keyword: searchKeyWord.value, + type: searchType, + ); + if (res['status']) { + if (currentPage == 1 && type == 'init') { + favList.value = res['data'].medias; + } else if (type == 'onLoad') { + favList.addAll(res['data'].medias); + } + hasMore = res['data'].hasMore; + } + currentPage += 1; + loadingStatus.value = false; + } + + onLoad() { + if (!hasMore) return; + searchFav(type: 'onLoad'); + } +} diff --git a/lib/pages/fav_search/index.dart b/lib/pages/fav_search/index.dart new file mode 100644 index 00000000..b811585f --- /dev/null +++ b/lib/pages/fav_search/index.dart @@ -0,0 +1,4 @@ +library fav_search; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/fav_search/view.dart b/lib/pages/fav_search/view.dart new file mode 100644 index 00000000..83c2440b --- /dev/null +++ b/lib/pages/fav_search/view.dart @@ -0,0 +1,116 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/skeleton/video_card_h.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; +import 'package:pilipala/pages/favDetail/widget/fav_video_card.dart'; + +import 'controller.dart'; + +class FavSearchPage extends StatefulWidget { + final int? sourceType; + final int? mediaId; + const FavSearchPage({super.key, this.sourceType, this.mediaId}); + + @override + State createState() => _FavSearchPageState(); +} + +class _FavSearchPageState extends State { + final FavSearchController _favSearchCtr = Get.put(FavSearchController()); + late ScrollController scrollController; + + @override + void initState() { + super.initState(); + + scrollController = _favSearchCtr.scrollController; + scrollController.addListener( + () { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + EasyThrottle.throttle('fav', const Duration(seconds: 1), () { + _favSearchCtr.onLoad(); + }); + } + }, + ); + } + + @override + void dispose() { + scrollController.removeListener(() {}); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + actions: [ + IconButton( + onPressed: () => _favSearchCtr.submit(), + icon: const Icon(Icons.search_outlined, size: 22)), + const SizedBox(width: 10) + ], + title: Obx( + () => TextField( + autofocus: true, + focusNode: _favSearchCtr.searchFocusNode, + controller: _favSearchCtr.controller.value, + textInputAction: TextInputAction.search, + onChanged: (value) => _favSearchCtr.onChange(value), + decoration: InputDecoration( + hintText: _favSearchCtr.hintText, + border: InputBorder.none, + suffixIcon: IconButton( + icon: Icon( + Icons.clear, + size: 22, + color: Theme.of(context).colorScheme.outline, + ), + onPressed: () => _favSearchCtr.onClear(), + ), + ), + onSubmitted: (String value) => _favSearchCtr.submit(), + ), + ), + ), + body: Obx( + () => _favSearchCtr.loadingStatus.value && _favSearchCtr.favList.isEmpty + ? ListView.builder( + itemCount: 10, + itemBuilder: (context, index) { + return const VideoCardHSkeleton(); + }, + ) + : _favSearchCtr.favList.isNotEmpty + ? ListView.builder( + controller: scrollController, + itemCount: _favSearchCtr.favList.length + 1, + itemBuilder: (context, index) { + if (index == _favSearchCtr.favList.length) { + return Container( + height: MediaQuery.of(context).padding.bottom + 60, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom), + ); + } else { + return FavVideoCardH( + videoItem: _favSearchCtr.favList[index], + callFn: () => null, + ); + } + }, + ) + : const CustomScrollView( + slivers: [ + NoData(), + ], + ), + ), + ); + } +} diff --git a/lib/pages/follow/controller.dart b/lib/pages/follow/controller.dart index fe1bfabc..fe4b6100 100644 --- a/lib/pages/follow/controller.dart +++ b/lib/pages/follow/controller.dart @@ -1,20 +1,28 @@ +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/http/follow.dart'; +import 'package:pilipala/http/member.dart'; import 'package:pilipala/models/follow/result.dart'; +import 'package:pilipala/models/member/tags.dart'; import 'package:pilipala/utils/storage.dart'; -class FollowController extends GetxController { +/// 查看自己的关注时,可以查看分类 +/// 查看其他人的关注时,只可以看全部 +class FollowController extends GetxController with GetTickerProviderStateMixin { Box userInfoCache = GStrorage.userInfo; int pn = 1; int ps = 20; int total = 0; - RxList followList = [FollowItemModel()].obs; + RxList followList = [].obs; late int mid; late String name; var userInfo; RxString loadingText = '加载中...'.obs; + RxBool isOwner = false.obs; + late List followTags; + late TabController tabController; @override void onInit() { @@ -23,6 +31,7 @@ class FollowController extends GetxController { mid = Get.parameters['mid'] != null ? int.parse(Get.parameters['mid']!) : userInfo.mid; + isOwner.value = mid == userInfo.mid; name = Get.parameters['name'] ?? userInfo.uname; } @@ -56,4 +65,20 @@ class FollowController extends GetxController { } return res; } + + // 当查看当前用户的关注时,请求关注分组 + Future followUpTags() async { + if (userInfo != null && mid == userInfo.mid) { + var res = await MemberHttp.followUpTags(); + if (res['status']) { + followTags = res['data']; + tabController = TabController( + initialIndex: 0, + length: res['data'].length, + vsync: this, + ); + } + return res; + } + } } diff --git a/lib/pages/follow/view.dart b/lib/pages/follow/view.dart index 9e290f0f..a4f1011b 100644 --- a/lib/pages/follow/view.dart +++ b/lib/pages/follow/view.dart @@ -1,12 +1,8 @@ -import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:pilipala/common/widgets/http_error.dart'; -import 'package:pilipala/common/widgets/no_data.dart'; -import 'package:pilipala/models/follow/result.dart'; - import 'controller.dart'; -import 'widgets/follow_item.dart'; +import 'widgets/follow_list.dart'; +import 'widgets/owner_follow_list.dart'; class FollowPage extends StatefulWidget { const FollowPage({super.key}); @@ -16,30 +12,15 @@ class FollowPage extends StatefulWidget { } class _FollowPageState extends State { - final FollowController _followController = Get.put(FollowController()); + late String mid; + late FollowController _followController; final ScrollController scrollController = ScrollController(); - Future? _futureBuilderFuture; @override void initState() { super.initState(); - _futureBuilderFuture = _followController.queryFollowings('init'); - scrollController.addListener( - () async { - if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent - 200) { - EasyThrottle.throttle('follow', const Duration(seconds: 1), () { - _followController.queryFollowings('onLoad'); - }); - } - }, - ); - } - - @override - void dispose() { - scrollController.removeListener(() {}); - super.dispose(); + mid = Get.parameters['mid']!; + _followController = Get.put(FollowController(), tag: mid); } @override @@ -51,73 +32,57 @@ class _FollowPageState extends State { titleSpacing: 0, centerTitle: false, title: Text( - '${_followController.name}的关注', + _followController.isOwner.value + ? '我的关注' + : '${_followController.name}的关注', style: Theme.of(context).textTheme.titleMedium, ), ), - body: RefreshIndicator( - onRefresh: () async => - await _followController.queryFollowings('init'), - child: FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - var data = snapshot.data; - if (data['status']) { - List list = _followController.followList; - return Obx( - () => list.isNotEmpty - ? ListView.builder( - controller: scrollController, - itemCount: list.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == list.length) { - return Container( - height: - MediaQuery.of(context).padding.bottom + - 60, - padding: EdgeInsets.only( - bottom: MediaQuery.of(context) - .padding - .bottom), - child: Center( - child: Obx( - () => Text( - _followController.loadingText.value, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .outline, - fontSize: 13), - ), - ), - ), - ); - } else { - return followItem(item: list[index]); - } - }, - ) - : const CustomScrollView( - slivers: [NoData()], + body: Obx( + () => !_followController.isOwner.value + ? FollowList(ctr: _followController) + : FutureBuilder( + future: _followController.followUpTags(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data; + if (data['status']) { + return Column( + children: [ + TabBar( + controller: _followController.tabController, + isScrollable: true, + tabs: [ + for (var i in data['data']) ...[ + Tab(text: i.name), + ] + ]), + Expanded( + child: TabBarView( + controller: _followController.tabController, + children: [ + for (var i = 0; + i < _followController.tabController.length; + i++) ...[ + OwnerFollowList( + ctr: _followController, + tagItem: _followController.followTags[i], + ) + ] + ], + ), ), - ); - } else { - return CustomScrollView( - slivers: [ - HttpError( - errMsg: data['msg'], - fn: () => _followController.queryFollowings('init'), - ) - ], - ); - } - } else { - // 骨架屏 - return const SizedBox(); - } - }, - )), + ], + ); + } else { + return const SizedBox(); + } + } else { + return const SizedBox(); + } + }, + ), + ), ); } } diff --git a/lib/pages/follow/widgets/follow_item.dart b/lib/pages/follow/widgets/follow_item.dart index d9b2617b..3f9e4f3c 100644 --- a/lib/pages/follow/widgets/follow_item.dart +++ b/lib/pages/follow/widgets/follow_item.dart @@ -1,38 +1,71 @@ import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/follow/result.dart'; +import 'package:pilipala/pages/follow/index.dart'; +import 'package:pilipala/pages/video/detail/introduction/widgets/group_panel.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/utils.dart'; -Widget followItem({item}) { - String heroTag = Utils.makeHeroTag(item!.mid); - return ListTile( - onTap: () { - feedBack(); - Get.toNamed('/member?mid=${item.mid}', - arguments: {'face': item.face, 'heroTag': heroTag}); - }, - leading: Hero( - tag: heroTag, - child: NetworkImgLayer( - width: 45, - height: 45, - type: 'avatar', - src: item.face, +class FollowItem extends StatelessWidget { + final FollowItemModel item; + final FollowController? ctr; + const FollowItem({super.key, required this.item, this.ctr}); + + @override + Widget build(BuildContext context) { + String heroTag = Utils.makeHeroTag(item.mid); + return ListTile( + onTap: () { + feedBack(); + Get.toNamed('/member?mid=${item.mid}', + arguments: {'face': item.face, 'heroTag': heroTag}); + }, + leading: Hero( + tag: heroTag, + child: NetworkImgLayer( + width: 45, + height: 45, + type: 'avatar', + src: item.face, + ), ), - ), - title: Text( - item.uname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - item.sign, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - dense: true, - trailing: const SizedBox(width: 6), - ); + title: Text( + item.uname!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + item.sign!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + dense: true, + trailing: ctr!.isOwner.value + ? SizedBox( + height: 34, + child: TextButton( + onPressed: () async { + await Get.bottomSheet( + GroupPanel(mid: item.mid!), + isScrollControlled: true, + ); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(15, 0, 15, 0), + foregroundColor: Theme.of(context).colorScheme.outline, + backgroundColor: + Theme.of(context).colorScheme.onInverseSurface, // 设置按钮背景色 + ), + child: const Text( + '已关注', + style: TextStyle(fontSize: 12), + ), + ), + ) + : const SizedBox(), + ); + } } diff --git a/lib/pages/follow/widgets/follow_list.dart b/lib/pages/follow/widgets/follow_list.dart new file mode 100644 index 00000000..d198bec2 --- /dev/null +++ b/lib/pages/follow/widgets/follow_list.dart @@ -0,0 +1,114 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; +import 'package:pilipala/models/follow/result.dart'; +import 'package:pilipala/pages/follow/index.dart'; + +import 'follow_item.dart'; + +class FollowList extends StatefulWidget { + final FollowController ctr; + const FollowList({ + super.key, + required this.ctr, + }); + + @override + State createState() => _FollowListState(); +} + +class _FollowListState extends State { + late Future _futureBuilderFuture; + final ScrollController scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _futureBuilderFuture = widget.ctr.queryFollowings('init'); + scrollController.addListener( + () async { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + EasyThrottle.throttle('follow', const Duration(seconds: 1), () { + widget.ctr.queryFollowings('onLoad'); + }); + } + }, + ); + } + + @override + void dispose() { + scrollController.removeListener(() {}); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () async => await widget.ctr.queryFollowings('init'), + child: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data; + if (data['status']) { + List list = widget.ctr.followList; + return Obx( + () => list.isNotEmpty + ? ListView.builder( + controller: scrollController, + itemCount: list.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == list.length) { + return Container( + height: + MediaQuery.of(context).padding.bottom + 60, + padding: EdgeInsets.only( + bottom: + MediaQuery.of(context).padding.bottom), + child: Center( + child: Obx( + () => Text( + widget.ctr.loadingText.value, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline, + fontSize: 13), + ), + ), + ), + ); + } else { + return FollowItem( + item: list[index], + ctr: widget.ctr, + ); + } + }, + ) + : const CustomScrollView(slivers: [NoData()]), + ); + } else { + return CustomScrollView( + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () => widget.ctr.queryFollowings('init'), + ) + ], + ); + } + } else { + // 骨架屏 + return const SizedBox(); + } + }, + ), + ); + } +} diff --git a/lib/pages/follow/widgets/owner_follow_list.dart b/lib/pages/follow/widgets/owner_follow_list.dart new file mode 100644 index 00000000..0dcd785d --- /dev/null +++ b/lib/pages/follow/widgets/owner_follow_list.dart @@ -0,0 +1,134 @@ +import 'dart:math'; + +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; +import 'package:pilipala/http/member.dart'; +import 'package:pilipala/models/follow/result.dart'; +import 'package:pilipala/models/member/tags.dart'; +import 'package:pilipala/pages/follow/index.dart'; +import 'follow_item.dart'; + +class OwnerFollowList extends StatefulWidget { + final FollowController ctr; + final MemberTagItemModel? tagItem; + const OwnerFollowList({super.key, required this.ctr, this.tagItem}); + + @override + State createState() => _OwnerFollowListState(); +} + +class _OwnerFollowListState extends State + with AutomaticKeepAliveClientMixin { + late int mid; + late Future _futureBuilderFuture; + final ScrollController scrollController = ScrollController(); + int pn = 1; + int ps = 20; + late MemberTagItemModel tagItem; + RxList followList = [].obs; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + mid = widget.ctr.mid; + tagItem = widget.tagItem!; + _futureBuilderFuture = followUpGroup('init'); + scrollController.addListener( + () async { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + EasyThrottle.throttle('follow', const Duration(seconds: 1), () { + followUpGroup('onLoad'); + }); + } + }, + ); + } + + // 获取分组下up + Future followUpGroup(type) async { + if (type == 'init') { + pn = 1; + } + var res = await MemberHttp.followUpGroup(mid, tagItem.tagid, pn, ps); + if (res['status']) { + if (res['data'].isNotEmpty) { + if (type == 'init') { + followList.value = res['data']; + } else { + followList.addAll(res['data']); + } + pn += 1; + } + } + return res; + } + + @override + void dispose() { + scrollController.removeListener(() {}); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return RefreshIndicator( + onRefresh: () async => await followUpGroup('init'), + child: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data; + if (data['status']) { + return Obx( + () => followList.isNotEmpty + ? ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + controller: scrollController, + itemCount: followList.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == followList.length) { + return Container( + height: + MediaQuery.of(context).padding.bottom + 60, + padding: EdgeInsets.only( + bottom: + MediaQuery.of(context).padding.bottom), + ); + } else { + return FollowItem( + item: followList[index], + ctr: widget.ctr, + ); + } + }, + ) + : const CustomScrollView(slivers: [NoData()]), + ); + } else { + return CustomScrollView( + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () => widget.ctr.queryFollowings('init'), + ) + ], + ); + } + } else { + // 骨架屏 + return const SizedBox(); + } + }, + ), + ); + } +} diff --git a/lib/pages/history/controller.dart b/lib/pages/history/controller.dart index ae897499..e7822cd9 100644 --- a/lib/pages/history/controller.dart +++ b/lib/pages/history/controller.dart @@ -8,11 +8,13 @@ import 'package:pilipala/utils/storage.dart'; class HistoryController extends GetxController { final ScrollController scrollController = ScrollController(); - RxList historyList = [HisListItem()].obs; + RxList historyList = [].obs; RxBool isLoadingMore = false.obs; RxBool pauseStatus = false.obs; Box localCache = GStrorage.localCache; RxBool isLoading = false.obs; + RxBool enableMultiple = false.obs; + RxInt checkedCount = 0.obs; @override void onInit() { @@ -121,4 +123,80 @@ class HistoryController extends GetxController { }, ); } + + // 删除某条历史记录 + Future delHistory(kid, business) async { + String resKid = 'archive_$kid'; + if (business == 'live') { + resKid = 'live_$kid'; + } else if (business.contains('article')) { + resKid = 'article_$kid'; + } + + var res = await UserHttp.delHistory(resKid); + if (res['status']) { + historyList.removeWhere((e) => e.kid == kid); + SmartDialog.showToast(res['msg']); + } + } + + // 删除已看历史记录 + Future onDelHistory() async { + /// TODO 优化 + List result = + historyList.where((e) => e.progress == -1).toList(); + for (HisListItem i in result) { + String resKid = 'archive_${i.kid}'; + await UserHttp.delHistory(resKid); + historyList.removeWhere((e) => e.kid == i.kid); + } + SmartDialog.showToast('操作完成'); + } + + // 删除选中的记录 + Future onDelCheckedHistory() async { + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: const Text('确认删除所选历史记录吗?'), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: Text( + '取消', + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + TextButton( + onPressed: () async { + /// TODO 优化 + await SmartDialog.dismiss(); + SmartDialog.showLoading(msg: '请求中'); + List result = + historyList.where((e) => e.checked!).toList(); + for (HisListItem i in result) { + String str = 'archive'; + try { + str = i.history!.business!; + } catch (_) {} + String resKid = '${str}_${i.kid}'; + await UserHttp.delHistory(resKid); + historyList.removeWhere((e) => e.kid == i.kid); + } + checkedCount.value = 0; + SmartDialog.dismiss(); + enableMultiple.value = false; + }, + child: const Text('确认'), + ) + ], + ); + }, + ); + } } diff --git a/lib/pages/history/view.dart b/lib/pages/history/view.dart index c0118819..d8fc60f0 100644 --- a/lib/pages/history/view.dart +++ b/lib/pages/history/view.dart @@ -37,6 +37,23 @@ class _HistoryPageState extends State { } }, ); + _historyController.enableMultiple.listen((p0) { + setState(() {}); + }); + } + + // 选中 + onChoose(index) { + _historyController.historyList[index].checked = + !_historyController.historyList[index].checked!; + _historyController.checkedCount.value = + _historyController.historyList.where((item) => item.checked!).length; + _historyController.historyList.refresh(); + } + + // 更新多选状态 + onUpdateMultiple() { + setState(() {}); } @override @@ -48,44 +65,112 @@ class _HistoryPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - titleSpacing: 0, - centerTitle: false, - title: Text( - '观看记录', - style: Theme.of(context).textTheme.titleMedium, - ), - actions: [ - PopupMenuButton( - onSelected: (String type) { - // 处理菜单项选择的逻辑 - switch (type) { - case 'pause': - _historyController.onPauseHistory(); - break; - case 'clear': - _historyController.onClearHistory(); - break; - default: - } - }, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - value: 'pause', - child: Obx( - () => Text(!_historyController.pauseStatus.value - ? '暂停观看记录' - : '恢复观看记录'), - ), - ), - const PopupMenuItem( - value: 'clear', - child: Text('清空观看记录'), - ), - ], + appBar: AppBarWidget( + visible: _historyController.enableMultiple.value, + child1: AppBar( + titleSpacing: 0, + centerTitle: false, + leading: IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back_outlined), ), - const SizedBox(width: 6), - ], + title: Text( + '观看记录', + style: Theme.of(context).textTheme.titleMedium, + ), + actions: [ + IconButton( + onPressed: () => Get.toNamed('/historySearch'), + icon: const Icon(Icons.search_outlined), + ), + PopupMenuButton( + onSelected: (String type) { + // 处理菜单项选择的逻辑 + switch (type) { + case 'pause': + _historyController.onPauseHistory(); + break; + case 'clear': + _historyController.onClearHistory(); + break; + case 'del': + _historyController.onDelHistory(); + break; + case 'multiple': + _historyController.enableMultiple.value = true; + setState(() {}); + break; + default: + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: 'pause', + child: Obx( + () => Text(!_historyController.pauseStatus.value + ? '暂停观看记录' + : '恢复观看记录'), + ), + ), + const PopupMenuItem( + value: 'clear', + child: Text('清空观看记录'), + ), + const PopupMenuItem( + value: 'del', + child: Text('删除已看记录'), + ), + const PopupMenuItem( + value: 'multiple', + child: Text('多选删除'), + ), + ], + ), + const SizedBox(width: 6), + ], + ), + child2: AppBar( + titleSpacing: 0, + centerTitle: false, + leading: IconButton( + onPressed: () { + _historyController.enableMultiple.value = false; + for (var item in _historyController.historyList) { + item.checked = false; + } + _historyController.checkedCount.value = 0; + setState(() {}); + }, + icon: const Icon(Icons.close_outlined), + ), + title: Obx( + () => Text( + '已选择${_historyController.checkedCount.value}项', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + actions: [ + TextButton( + onPressed: () { + for (var item in _historyController.historyList) { + item.checked = true; + } + _historyController.checkedCount.value = + _historyController.historyList.length; + _historyController.historyList.refresh(); + }, + child: const Text('全选'), + ), + TextButton( + onPressed: () => _historyController.onDelCheckedHistory(), + child: Text( + '删除', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + const SizedBox(width: 6), + ], + ), ), body: RefreshIndicator( onRefresh: () async { @@ -99,6 +184,9 @@ class _HistoryPageState extends State { future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } Map data = snapshot.data; if (data['status']) { return Obx( @@ -109,6 +197,9 @@ class _HistoryPageState extends State { return HistoryItem( videoItem: _historyController.historyList[index], + ctr: _historyController, + onChoose: () => onChoose(index), + onUpdateMultiple: () => onUpdateMultiple(), ); }, childCount: @@ -144,6 +235,36 @@ class _HistoryPageState extends State { ], ), ), + // bottomNavigationBar: BottomAppBar(), + ); + } +} + +class AppBarWidget extends StatelessWidget implements PreferredSizeWidget { + const AppBarWidget({ + required this.child1, + required this.child2, + required this.visible, + Key? key, + }) : super(key: key); + + final PreferredSizeWidget child1; + final PreferredSizeWidget child2; + final bool visible; + @override + Size get preferredSize => child1.preferredSize; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: !visible ? child1 : child2, ); } } diff --git a/lib/pages/history/widgets/item.dart b/lib/pages/history/widgets/item.dart index 2d801668..b80affc8 100644 --- a/lib/pages/history/widgets/item.dart +++ b/lib/pages/history/widgets/item.dart @@ -11,12 +11,24 @@ import 'package:pilipala/models/bangumi/info.dart'; import 'package:pilipala/models/common/business_type.dart'; import 'package:pilipala/models/common/search_type.dart'; import 'package:pilipala/models/live/item.dart'; +import 'package:pilipala/pages/history/index.dart'; +import 'package:pilipala/pages/history_search/index.dart'; +import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/utils.dart'; class HistoryItem extends StatelessWidget { final dynamic videoItem; - const HistoryItem({super.key, required this.videoItem}); + final dynamic ctr; + final Function? onChoose; + final Function? onUpdateMultiple; + const HistoryItem({ + super.key, + required this.videoItem, + this.ctr, + this.onChoose, + this.onUpdateMultiple, + }); @override Widget build(BuildContext context) { @@ -25,6 +37,11 @@ class HistoryItem extends StatelessWidget { String heroTag = Utils.makeHeroTag(aid); return InkWell( onTap: () async { + if (ctr!.enableMultiple.value) { + feedBack(); + onChoose!(); + return; + } if (videoItem.history.business.contains('article')) { int cid = videoItem.history.cid ?? // videoItem.history.oid ?? @@ -115,6 +132,17 @@ class HistoryItem extends StatelessWidget { arguments: {'heroTag': heroTag, 'pic': videoItem.cover}); } }, + onLongPress: () { + if (ctr is HistorySearchController) { + return; + } + if (!ctr!.enableMultiple.value) { + feedBack(); + ctr!.enableMultiple.value = true; + onChoose!(); + onUpdateMultiple!(); + } + }, child: Column( children: [ Padding( @@ -130,53 +158,110 @@ class HistoryItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( - aspectRatio: StyleString.aspectRatio, - child: LayoutBuilder( - builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; - return Stack( - children: [ - Hero( - tag: heroTag, - child: NetworkImgLayer( - src: (videoItem.cover != '' - ? videoItem.cover - : videoItem.covers.first), - width: maxWidth, - height: maxHeight, + Stack( + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Stack( + children: [ + Hero( + tag: heroTag, + child: NetworkImgLayer( + src: (videoItem.cover != '' + ? videoItem.cover + : videoItem.covers.first), + width: maxWidth, + height: maxHeight, + ), + ), + if (!BusinessType + .hiddenDurationType.hiddenDurationType + .contains(videoItem.history.business)) + PBadge( + text: videoItem.progress == -1 + ? '已看完' + : '${Utils.timeFormat(videoItem.progress!)}/${Utils.timeFormat(videoItem.duration!)}', + right: 6.0, + bottom: 6.0, + type: 'gray', + ), + // 右上角 + if (BusinessType.showBadge.showBadge + .contains( + videoItem.history.business) || + videoItem.history.business == + BusinessType.live.type) + PBadge( + text: videoItem.badge, + top: 6.0, + right: 6.0, + bottom: null, + left: null, + ), + ], + ); + }, + ), + ), + Obx( + () => Positioned.fill( + child: AnimatedOpacity( + opacity: ctr!.enableMultiple.value ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.black.withOpacity( + ctr!.enableMultiple.value && + videoItem.checked + ? 0.6 + : 0), + ), + child: Center( + child: SizedBox( + width: 34, + height: 34, + child: AnimatedScale( + scale: videoItem.checked ? 1 : 0, + duration: + const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all( + EdgeInsets.zero), + backgroundColor: + MaterialStateProperty + .resolveWith( + (states) { + return Colors.white + .withOpacity(0.8); + }, + ), + ), + onPressed: () { + feedBack(); + onChoose!(); + }, + icon: Icon(Icons.done_all_outlined, + color: Theme.of(context) + .colorScheme + .primary), + ), + ), + ), ), ), - if (!BusinessType - .hiddenDurationType.hiddenDurationType - .contains(videoItem.history.business)) - PBadge( - text: videoItem.progress == -1 - ? '已看完' - : '${Utils.timeFormat(videoItem.progress!)}/${Utils.timeFormat(videoItem.duration!)}', - right: 6.0, - bottom: 6.0, - type: 'gray', - ), - // 右上角 - if (BusinessType.showBadge.showBadge - .contains(videoItem.history.business) || - videoItem.history.business == - BusinessType.live.type) - PBadge( - text: videoItem.badge, - top: 6.0, - right: 6.0, - bottom: null, - left: null, - ), - ], - ); - }, - ), + ), + ), + ), + ], ), - VideoContent(videoItem: videoItem) + VideoContent(videoItem: videoItem, ctr: ctr) ], ), ); @@ -191,7 +276,8 @@ class HistoryItem extends StatelessWidget { class VideoContent extends StatelessWidget { final dynamic videoItem; - const VideoContent({super.key, required this.videoItem}); + final dynamic ctr; + const VideoContent({super.key, required this.videoItem, this.ctr}); @override Widget build(BuildContext context) { @@ -211,7 +297,8 @@ class VideoContent extends StatelessWidget { maxLines: videoItem.videos > 1 ? 1 : 2, overflow: TextOverflow.ellipsis, ), - if (videoItem.showTitle != null) + if (videoItem.showTitle != null) ...[ + const SizedBox(height: 2), Text( videoItem.showTitle, textAlign: TextAlign.start, @@ -219,21 +306,24 @@ class VideoContent extends StatelessWidget { fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, fontWeight: FontWeight.w400, color: Theme.of(context).colorScheme.outline), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, ), + ], const Spacer(), - Row( - children: [ - Text( - videoItem.authorName, - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, - color: Theme.of(context).colorScheme.outline, + if (videoItem.authorName != '') + Row( + children: [ + Text( + videoItem.authorName, + style: TextStyle( + fontSize: + Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), ), - ), - ], - ), + ], + ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -244,26 +334,26 @@ class VideoContent extends StatelessWidget { Theme.of(context).textTheme.labelMedium!.fontSize, color: Theme.of(context).colorScheme.outline), ), - if (videoItem.badge != '番剧' && - !videoItem.tagName.contains('动画') && - videoItem.history.business != 'live' && - !videoItem.history.business.contains('article')) - SizedBox( - width: 24, - height: 24, - child: PopupMenuButton( - padding: EdgeInsets.zero, - tooltip: '稍后再看', - icon: Icon( - Icons.more_vert_outlined, - color: Theme.of(context).colorScheme.outline, - size: 14, - ), - position: PopupMenuPosition.under, - // constraints: const BoxConstraints(maxHeight: 35), - onSelected: (String type) {}, - itemBuilder: (BuildContext context) => - >[ + SizedBox( + width: 24, + height: 24, + child: PopupMenuButton( + padding: EdgeInsets.zero, + tooltip: '功能菜单', + icon: Icon( + Icons.more_vert_outlined, + color: Theme.of(context).colorScheme.outline, + size: 14, + ), + position: PopupMenuPosition.under, + // constraints: const BoxConstraints(maxHeight: 35), + onSelected: (String type) {}, + itemBuilder: (BuildContext context) => + >[ + if (videoItem.badge != '番剧' && + !videoItem.tagName.contains('动画') && + videoItem.history.business != 'live' && + !videoItem.history.business.contains('article')) PopupMenuItem( onTap: () async { var res = await UserHttp.toViewLater( @@ -280,9 +370,22 @@ class VideoContent extends StatelessWidget { ], ), ), - ], - ), + PopupMenuItem( + onTap: () => ctr!.delHistory( + videoItem.kid, videoItem.history.business), + value: 'pause', + height: 35, + child: const Row( + children: [ + Icon(Icons.close_outlined, size: 16), + SizedBox(width: 6), + Text('删除记录', style: TextStyle(fontSize: 13)) + ], + ), + ), + ], ), + ), ], ), ], diff --git a/lib/pages/history_search/controller.dart b/lib/pages/history_search/controller.dart new file mode 100644 index 00000000..90ac7a02 --- /dev/null +++ b/lib/pages/history_search/controller.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/user.dart'; +import 'package:pilipala/models/user/history.dart'; + +class HistorySearchController extends GetxController { + final ScrollController scrollController = ScrollController(); + Rx controller = TextEditingController().obs; + final FocusNode searchFocusNode = FocusNode(); + RxString searchKeyWord = ''.obs; + String hintText = '搜索'; + RxString loadingStatus = 'init'.obs; + RxString loadingText = '加载中...'.obs; + bool hasRequest = false; + late int mid; + RxString uname = ''.obs; + int pn = 1; + int count = 0; + RxList historyList = [].obs; + RxBool enableMultiple = false.obs; + + // 清空搜索 + void onClear() { + if (searchKeyWord.value.isNotEmpty && controller.value.text != '') { + controller.value.clear(); + searchKeyWord.value = ''; + } else { + Get.back(); + } + } + + void onChange(value) { + searchKeyWord.value = value; + } + + // 提交搜索内容 + void submit() { + loadingStatus.value = 'loading'; + if (hasRequest) { + pn = 1; + searchHistories(); + } + } + + // 搜索视频 + Future searchHistories({type = 'init'}) async { + if (type == 'onLoad' && loadingText.value == '没有更多了') { + return; + } + var res = await UserHttp.searchHistory( + pn: pn, + keyword: controller.value.text, + ); + if (res['status']) { + if (type == 'init' && pn == 1) { + historyList.value = res['data'].list; + } else { + historyList.addAll(res['data'].list); + } + count = res['data'].page['total']; + if (historyList.length == count) { + loadingText.value = '没有更多了'; + } + pn += 1; + hasRequest = true; + } + loadingStatus.value = 'finish'; + return res; + } + + onLoad() { + searchHistories(type: 'onLoad'); + } + + Future delHistory(kid, business) async { + String resKid = 'archive_$kid'; + if (business == 'live') { + resKid = 'live_$kid'; + } else if (business.contains('article')) { + resKid = 'article_$kid'; + } + + var res = await UserHttp.delHistory(resKid); + if (res['status']) { + historyList.removeWhere((e) => e.kid == kid); + SmartDialog.showToast(res['msg']); + } + loadingStatus.value = 'finish'; + } +} diff --git a/lib/pages/history_search/index.dart b/lib/pages/history_search/index.dart new file mode 100644 index 00000000..a9db082b --- /dev/null +++ b/lib/pages/history_search/index.dart @@ -0,0 +1,4 @@ +library history_search; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/history_search/view.dart b/lib/pages/history_search/view.dart new file mode 100644 index 00000000..809e6d67 --- /dev/null +++ b/lib/pages/history_search/view.dart @@ -0,0 +1,174 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/skeleton/video_card_h.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; +import 'package:pilipala/pages/history/widgets/item.dart'; + +import 'controller.dart'; + +class HistorySearchPage extends StatefulWidget { + const HistorySearchPage({super.key}); + + @override + State createState() => _HistorySearchPageState(); +} + +class _HistorySearchPageState extends State { + final HistorySearchController _historySearchCtr = + Get.put(HistorySearchController()); + late ScrollController scrollController; + + @override + void initState() { + super.initState(); + scrollController = _historySearchCtr.scrollController; + scrollController.addListener( + () { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + EasyThrottle.throttle('history', const Duration(seconds: 1), () { + _historySearchCtr.onLoad(); + }); + } + }, + ); + } + + @override + void dispose() { + scrollController.removeListener(() {}); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + actions: [ + IconButton( + onPressed: () => _historySearchCtr.submit(), + icon: const Icon(Icons.search_outlined, size: 22)), + const SizedBox(width: 10) + ], + title: Obx( + () => TextField( + autofocus: true, + focusNode: _historySearchCtr.searchFocusNode, + controller: _historySearchCtr.controller.value, + textInputAction: TextInputAction.search, + onChanged: (value) => _historySearchCtr.onChange(value), + decoration: InputDecoration( + hintText: _historySearchCtr.hintText, + border: InputBorder.none, + suffixIcon: IconButton( + icon: Icon( + Icons.clear, + size: 22, + color: Theme.of(context).colorScheme.outline, + ), + onPressed: () => _historySearchCtr.onClear(), + ), + ), + onSubmitted: (String value) => _historySearchCtr.submit(), + ), + ), + ), + body: Obx( + () => Column( + children: _historySearchCtr.loadingStatus.value == 'init' + ? [const SizedBox()] + : [ + Expanded( + child: FutureBuilder( + future: _historySearchCtr.searchHistories(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + return Obx( + () => _historySearchCtr.historyList.isNotEmpty + ? ListView.builder( + controller: scrollController, + itemCount: + _historySearchCtr.historyList.length + + 1, + itemBuilder: (context, index) { + if (index == + _historySearchCtr + .historyList.length) { + return Container( + height: MediaQuery.of(context) + .padding + .bottom + + 60, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .padding + .bottom), + child: Center( + child: Obx( + () => Text( + _historySearchCtr + .loadingText.value, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline, + fontSize: 13), + ), + ), + ), + ); + } else { + return HistoryItem( + videoItem: _historySearchCtr + .historyList[index], + ctr: _historySearchCtr, + onChoose: null, + onUpdateMultiple: () => null, + ); + ; + } + }, + ) + : _historySearchCtr.loadingStatus.value == + 'loading' + ? const SizedBox(child: Text('加载中...')) + : const CustomScrollView( + slivers: [ + NoData(), + ], + ), + ); + } else { + return CustomScrollView( + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ) + ], + ); + } + } else { + // 骨架屏 + return ListView.builder( + itemCount: 10, + itemBuilder: (context, index) { + return const VideoCardHSkeleton(); + }, + ); + } + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/html/controller.dart b/lib/pages/html/controller.dart new file mode 100644 index 00000000..f3187828 --- /dev/null +++ b/lib/pages/html/controller.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/http/html.dart'; +import 'package:pilipala/http/reply.dart'; +import 'package:pilipala/models/common/reply_sort_type.dart'; +import 'package:pilipala/models/video/reply/item.dart'; +import 'package:pilipala/utils/feed_back.dart'; +import 'package:pilipala/utils/storage.dart'; + +class HtmlRenderController extends GetxController { + late String id; + late String dynamicType; + late int type; + RxInt oid = (-1).obs; + late Map response; + int? floor; + int currentPage = 0; + bool isLoadingMore = false; + RxString noMore = ''.obs; + RxList replyList = [].obs; + RxInt acount = 0.obs; + final ScrollController scrollController = ScrollController(); + + ReplySortType _sortType = ReplySortType.time; + RxString sortTypeTitle = ReplySortType.time.titles.obs; + RxString sortTypeLabel = ReplySortType.time.labels.obs; + Box setting = GStrorage.setting; + + @override + void onInit() { + super.onInit(); + id = Get.parameters['id']!; + dynamicType = Get.parameters['dynamicType']!; + type = dynamicType == 'picture' ? 11 : 12; + } + + // 请求动态内容 + Future reqHtml(id) async { + late dynamic res; + if (dynamicType == 'opus' || dynamicType == 'picture') { + res = await HtmlHttp.reqHtml(id, dynamicType); + } else { + res = await HtmlHttp.reqReadHtml(id, dynamicType); + } + response = res; + oid.value = res['commentId']; + return res; + } + + // 请求评论 + Future queryReplyList({reqType = 'init'}) async { + var res = await ReplyHttp.replyList( + oid: oid.value, + pageNum: currentPage + 1, + type: type, + sort: _sortType.index, + ); + if (res['status']) { + List replies = res['data'].replies; + acount.value = res['data'].page.acount; + if (replies.isNotEmpty) { + currentPage++; + noMore.value = '加载中...'; + if (replies.length < 20) { + noMore.value = '没有更多了'; + } + } else { + noMore.value = currentPage == 0 ? '还没有评论' : '没有更多了'; + } + if (reqType == 'init') { + // 添加置顶回复 + if (res['data'].upper.top != null) { + bool flag = res['data'] + .topReplies + .any((reply) => reply.rpid == res['data'].upper.top.rpid); + if (!flag) { + replies.insert(0, res['data'].upper.top); + } + } + replies.insertAll(0, res['data'].topReplies); + replyList.value = replies; + } else { + replyList.addAll(replies); + } + } + isLoadingMore = false; + return res; + } + + // 排序搜索评论 + queryBySort() { + feedBack(); + switch (_sortType) { + case ReplySortType.time: + _sortType = ReplySortType.like; + break; + case ReplySortType.like: + _sortType = ReplySortType.reply; + break; + case ReplySortType.reply: + _sortType = ReplySortType.time; + break; + default: + } + sortTypeTitle.value = _sortType.titles; + sortTypeLabel.value = _sortType.labels; + currentPage = 0; + replyList.clear(); + queryReplyList(reqType: 'init'); + } +} diff --git a/lib/pages/html/index.dart b/lib/pages/html/index.dart new file mode 100644 index 00000000..c62e60b7 --- /dev/null +++ b/lib/pages/html/index.dart @@ -0,0 +1,4 @@ +library html_render; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/html/view.dart b/lib/pages/html/view.dart new file mode 100644 index 00000000..64fabff8 --- /dev/null +++ b/lib/pages/html/view.dart @@ -0,0 +1,457 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/skeleton/video_reply.dart'; +import 'package:pilipala/common/widgets/html_render.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/common/reply_type.dart'; +import 'package:pilipala/pages/mine/index.dart'; +import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; +import 'package:pilipala/pages/video/detail/replyNew/index.dart'; +import 'package:pilipala/pages/video/detail/replyReply/index.dart'; +import 'package:pilipala/utils/feed_back.dart'; + +import 'controller.dart'; + +class HtmlRenderPage extends StatefulWidget { + const HtmlRenderPage({super.key}); + + @override + State createState() => _HtmlRenderPageState(); +} + +class _HtmlRenderPageState extends State + with TickerProviderStateMixin { + final HtmlRenderController _htmlRenderCtr = Get.put(HtmlRenderController()); + late String title; + late String id; + late String url; + late String dynamicType; + late int type; + bool _isFabVisible = true; + late Future _futureBuilderFuture; + late ScrollController scrollController; + late AnimationController fabAnimationCtr; + + @override + void initState() { + super.initState(); + title = Get.parameters['title']!; + id = Get.parameters['id']!; + url = Get.parameters['url']!; + dynamicType = Get.parameters['dynamicType']!; + type = dynamicType == 'picture' ? 11 : 12; + _futureBuilderFuture = _htmlRenderCtr.reqHtml(id); + fabAnimationCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + scrollListener(); + } + + void scrollListener() { + scrollController = _htmlRenderCtr.scrollController; + scrollController.addListener( + () { + // 分页加载 + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + EasyThrottle.throttle('replylist', const Duration(seconds: 2), () { + _htmlRenderCtr.queryReplyList(reqType: 'onLoad'); + }); + } + + // 标题 + // if (scrollController.offset > 55 && !_visibleTitle) { + // _visibleTitle = true; + // titleStreamC.add(true); + // } else if (scrollController.offset <= 55 && _visibleTitle) { + // _visibleTitle = false; + // titleStreamC.add(false); + // } + + // fab按钮 + final ScrollDirection direction = + scrollController.position.userScrollDirection; + if (direction == ScrollDirection.forward) { + _showFab(); + } else if (direction == ScrollDirection.reverse) { + _hideFab(); + } + }, + ); + } + + void _showFab() { + if (!_isFabVisible) { + _isFabVisible = true; + fabAnimationCtr.forward(); + } + } + + void _hideFab() { + if (_isFabVisible) { + _isFabVisible = false; + fabAnimationCtr.reverse(); + } + } + + void replyReply(replyItem) { + int oid = replyItem.oid; + int rpid = replyItem.rpid!; + Get.to( + () => Scaffold( + appBar: AppBar( + titleSpacing: 0, + centerTitle: false, + title: Text( + '评论详情', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + body: VideoReplyReplyPanel( + oid: oid, + rpid: rpid, + source: 'dynamic', + replyType: ReplyType.values[type], + firstFloor: replyItem, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + actions: [ + const SizedBox(width: 4), + IconButton( + onPressed: () { + Get.toNamed('/webview', parameters: { + 'url': url.startsWith('http') ? url : 'https:$url', + 'type': 'url', + 'pageTitle': title, + }); + }, + icon: const Icon(Icons.open_in_browser_outlined, size: 19), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + onTap: () => { + Clipboard.setData(ClipboardData(text: url)), + SmartDialog.showToast('已复制'), + }, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.copy_rounded, size: 19), + SizedBox(width: 10), + Text('复制链接'), + ], + ), + ), + PopupMenuItem( + onTap: () => {}, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.share_outlined, size: 19), + SizedBox(width: 10), + Text('分享'), + ], + ), + ), + ], + ), + const SizedBox(width: 6) + ], + ), + body: Stack( + children: [ + SingleChildScrollView( + controller: scrollController, + child: Column( + children: [ + FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data; + fabAnimationCtr.forward(); + if (data['status']) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Row( + children: [ + NetworkImgLayer( + width: 40, + height: 40, + type: 'avatar', + src: _htmlRenderCtr.response['avatar']!, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(_htmlRenderCtr.response['uname'], + style: TextStyle( + fontSize: Theme.of(context) + .textTheme + .titleSmall! + .fontSize, + )), + Text( + _htmlRenderCtr.response['updateTime'], + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline, + fontSize: Theme.of(context) + .textTheme + .labelSmall! + .fontSize, + ), + ), + ], + ), + const Spacer(), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: HtmlRender( + htmlContent: _htmlRenderCtr.response['content'], + ), + ), + Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 8, + color: Theme.of(context) + .dividerColor + .withOpacity(0.05), + ), + ), + ), + ), + ], + ); + } else { + return const Text('error'); + } + } else { + // 骨架屏 + return const SizedBox(); + } + }, + ), + Obx( + () => _htmlRenderCtr.oid.value != -1 + ? Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + width: 0.6, + color: Theme.of(context) + .dividerColor + .withOpacity(0.05), + ), + ), + ), + height: 45, + padding: const EdgeInsets.only(left: 12, right: 6), + child: Row( + children: [ + const Text('回复'), + const Spacer(), + SizedBox( + height: 35, + child: TextButton.icon( + onPressed: () => _htmlRenderCtr.queryBySort(), + icon: const Icon(Icons.sort, size: 16), + label: Obx( + () => Text( + _htmlRenderCtr.sortTypeLabel.value, + style: const TextStyle(fontSize: 13), + ), + ), + ), + ) + ], + ), + ) + : const SizedBox(), + ), + Obx( + () => _htmlRenderCtr.oid.value != -1 + ? FutureBuilder( + future: _htmlRenderCtr.queryReplyList(), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.done) { + Map data = snapshot.data as Map; + if (snapshot.data['status']) { + // 请求成功 + return Obx( + () => _htmlRenderCtr.replyList.isEmpty && + _htmlRenderCtr.isLoadingMore + ? ListView.builder( + itemCount: 5, + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return const VideoReplySkeleton(); + }, + ) + : ListView.builder( + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + itemCount: + _htmlRenderCtr.replyList.length + + 1, + itemBuilder: (context, index) { + if (index == + _htmlRenderCtr + .replyList.length) { + return Container( + padding: EdgeInsets.only( + bottom: + MediaQuery.of(context) + .padding + .bottom), + height: MediaQuery.of(context) + .padding + .bottom + + 100, + child: Center( + child: Obx( + () => Text( + _htmlRenderCtr + .noMore.value, + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .outline, + ), + ), + ), + ), + ); + } else { + return ReplyItem( + replyItem: _htmlRenderCtr + .replyList[index], + showReplyRow: true, + replyLevel: '1', + replyReply: (replyItem) => + replyReply(replyItem), + replyType: + ReplyType.values[type], + addReply: (replyItem) { + _htmlRenderCtr + .replyList[index].replies! + .add(replyItem); + }, + ); + } + }, + ), + ); + } else { + // 请求错误 + return CustomScrollView( + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ) + ], + ); + } + } else { + // 骨架屏 + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 5, + itemBuilder: (context, index) { + return const VideoReplySkeleton(); + }, + ); + } + }, + ) + : const SizedBox(), + ) + ], + ), + ), + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 14, + right: 14, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 2), + end: const Offset(0, 0), + ).animate(CurvedAnimation( + parent: fabAnimationCtr, + curve: Curves.easeInOut, + )), + child: FloatingActionButton( + heroTag: null, + onPressed: () { + feedBack(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return VideoReplyNewDialog( + oid: _htmlRenderCtr.oid.value, + root: 0, + parent: 0, + replyType: ReplyType.values[type], + ); + }, + ).then( + (value) => { + // 完成评论,数据添加 + if (value != null && value['data'] != null) + { + _htmlRenderCtr.replyList.add(value['data']), + _htmlRenderCtr.acount.value++ + } + }, + ); + }, + tooltip: '评论动态', + child: const Icon(Icons.reply), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/live/controller.dart b/lib/pages/live/controller.dart index 8cdf53a7..6a26f0d2 100644 --- a/lib/pages/live/controller.dart +++ b/lib/pages/live/controller.dart @@ -20,14 +20,14 @@ class LiveController extends GetxController { void onInit() { super.onInit(); crossAxisCount.value = - setting.get(SettingBoxKey.enableSingleRow, defaultValue: false) ? 1 : 2; + setting.get(SettingBoxKey.customRows, defaultValue: 2); } // 获取推荐 Future queryLiveList(type) async { - if (type == 'init') { - _currentPage = 1; - } + // if (type == 'init') { + // _currentPage = 1; + // } var res = await LiveHttp.liveList( pn: _currentPage, ); diff --git a/lib/pages/live/view.dart b/lib/pages/live/view.dart index 5e3e68a1..1fbff63c 100644 --- a/lib/pages/live/view.dart +++ b/lib/pages/live/view.dart @@ -10,6 +10,7 @@ import 'package:pilipala/common/widgets/animated_dialog.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/overlay_pop.dart'; import 'package:pilipala/pages/main/index.dart'; +import 'package:pilipala/pages/rcmd/index.dart'; import 'controller.dart'; import 'widgets/live_item.dart'; @@ -21,11 +22,15 @@ class LivePage extends StatefulWidget { State createState() => _LivePageState(); } -class _LivePageState extends State { +class _LivePageState extends State + with AutomaticKeepAliveClientMixin { final LiveController _liveController = Get.put(LiveController()); late Future _futureBuilderFuture; late ScrollController scrollController; + @override + bool get wantKeepAlive => true; + @override void initState() { super.initState(); @@ -37,7 +42,7 @@ class _LivePageState extends State { () { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { - EasyThrottle.throttle('my-throttler', const Duration(seconds: 1), () { + EasyThrottle.throttle('liveList', const Duration(seconds: 1), () { _liveController.isLoadingMore = true; _liveController.onLoad(); }); @@ -84,6 +89,9 @@ class _LivePageState extends State { future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } Map data = snapshot.data as Map; if (data['status']) { return SliverLayoutBuilder( @@ -111,7 +119,7 @@ class _LivePageState extends State { }, ), ), - const LoadingMore() + LoadingMore(ctr: _liveController) ], ), ), @@ -141,9 +149,9 @@ class _LivePageState extends State { return SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( // 行间距 - mainAxisSpacing: StyleString.cardSpace + 4, + mainAxisSpacing: StyleString.safeSpace, // 列间距 - crossAxisSpacing: StyleString.cardSpace + 4, + crossAxisSpacing: StyleString.safeSpace, // 列数 crossAxisCount: crossAxisCount, mainAxisExtent: @@ -173,24 +181,3 @@ class _LivePageState extends State { ); } } - -class LoadingMore extends StatelessWidget { - const LoadingMore({super.key}); - - @override - Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: Container( - height: MediaQuery.of(context).padding.bottom + 80, - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), - child: Center( - child: Text( - '加载中...', - style: TextStyle( - color: Theme.of(context).colorScheme.outline, fontSize: 13), - ), - ), - ), - ); - } -} diff --git a/lib/pages/live/widgets/live_item.dart b/lib/pages/live/widgets/live_item.dart index 48a4356e..8fa797fb 100644 --- a/lib/pages/live/widgets/live_item.dart +++ b/lib/pages/live/widgets/live_item.dart @@ -24,7 +24,7 @@ class LiveCardV extends StatelessWidget { Widget build(BuildContext context) { String heroTag = Utils.makeHeroTag(liveItem.roomId); return Card( - elevation: crossAxisCount == 1 ? 0 : 1, + elevation: 0, clipBehavior: Clip.hardEdge, margin: EdgeInsets.zero, child: GestureDetector( @@ -102,7 +102,7 @@ class LiveContent extends StatelessWidget { child: Padding( padding: crossAxisCount == 1 ? const EdgeInsets.fromLTRB(9, 9, 9, 4) - : const EdgeInsets.fromLTRB(9, 8, 9, 8), + : const EdgeInsets.fromLTRB(5, 8, 5, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -120,15 +120,18 @@ class LiveContent extends StatelessWidget { if (crossAxisCount == 1) const SizedBox(height: 4), Row( children: [ - Text( - liveItem.uname, - textAlign: TextAlign.start, - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, - color: Theme.of(context).colorScheme.outline, + Expanded( + child: Text( + liveItem.uname, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: + Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), if (crossAxisCount == 1) ...[ Text( @@ -169,7 +172,7 @@ class VideoStat extends StatelessWidget { Widget build(BuildContext context) { return Container( height: 50, - padding: const EdgeInsets.only(top: 22, left: 10, right: 10), + padding: const EdgeInsets.only(top: 26, left: 10, right: 10), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -181,18 +184,17 @@ class VideoStat extends StatelessWidget { tileMode: TileMode.mirror, ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - liveItem!.areaName!, - style: const TextStyle(fontSize: 11, color: Colors.white), - ), - Text( - liveItem!.watchedShow!['text_small'], - style: const TextStyle(fontSize: 11, color: Colors.white), - ), - ], + child: RichText( + maxLines: 1, + textAlign: TextAlign.justify, + softWrap: false, + text: TextSpan( + style: const TextStyle(fontSize: 11, color: Colors.white), + children: [ + TextSpan(text: liveItem!.areaName!), + TextSpan(text: liveItem!.watchedShow!['text_small']), + ], + ), ), ); } diff --git a/lib/pages/liveRoom/view.dart b/lib/pages/liveRoom/view.dart index fa881cb8..36b1f979 100644 --- a/lib/pages/liveRoom/view.dart +++ b/lib/pages/liveRoom/view.dart @@ -1,9 +1,13 @@ +import 'dart:io'; + +import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'controller.dart'; +import 'widgets/bottom_control.dart'; class LiveRoomPage extends StatefulWidget { const LiveRoomPage({super.key}); @@ -18,6 +22,7 @@ class _LiveRoomPageState extends State { bool isShowCover = true; bool isPlay = true; + Floating? floating; @override void initState() { @@ -31,19 +36,24 @@ class _LiveRoomPageState extends State { } }, ); + if (Platform.isAndroid) { + floating = Floating(); + } } @override void dispose() { plPlayerController!.dispose(); + if (floating != null) { + floating!.dispose(); + } super.dispose(); } @override Widget build(BuildContext context) { final videoHeight = MediaQuery.of(context).size.width * 9 / 16; - - return Scaffold( + Widget childWhenDisabled = Scaffold( primary: true, appBar: AppBar( centerTitle: false, @@ -87,98 +97,61 @@ class _LiveRoomPageState extends State { ), body: Column( children: [ - Hero( - tag: _liveRoomController.heroTag, - child: Stack( - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: plPlayerController!.videoPlayerController != null - ? PLVideoPlayer(controller: plPlayerController!) - : const SizedBox(), - ), - // if (_liveRoomController.liveItem != null && - // _liveRoomController.liveItem.cover != null) - // Visibility( - // visible: isShowCover, - // child: Positioned( - // top: 0, - // left: 0, - // right: 0, - // child: NetworkImgLayer( - // type: 'emote', - // src: _liveRoomController.liveItem.cover, - // width: Get.size.width, - // height: videoHeight, - // ), - // ), - // ), - ], - ), + Stack( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: plPlayerController!.videoPlayerController != null + ? PLVideoPlayer( + controller: plPlayerController!, + bottomControl: BottomControl( + controller: plPlayerController, + liveRoomCtr: _liveRoomController, + floating: floating, + ), + ) + : const SizedBox(), + ), + // if (_liveRoomController.liveItem != null && + // _liveRoomController.liveItem.cover != null) + // Visibility( + // visible: isShowCover, + // child: Positioned( + // top: 0, + // left: 0, + // right: 0, + // child: NetworkImgLayer( + // type: 'emote', + // src: _liveRoomController.liveItem.cover, + // width: Get.size.width, + // height: videoHeight, + // ), + // ), + // ), + ], ), - // Container( - // height: 45, - // padding: const EdgeInsets.only(left: 12, right: 12), - // decoration: BoxDecoration( - // color: Theme.of(context).colorScheme.background, - // border: Border( - // bottom: BorderSide( - // color: Theme.of(context).dividerColor.withOpacity(0.1)), - // ), - // ), - // child: Row(children: [ - // SizedBox( - // width: 38, - // height: 38, - // child: IconButton( - // onPressed: () {}, - // icon: const Icon( - // Icons.subtitles_outlined, - // size: 21, - // ), - // ), - // ), - // const Spacer(), - // SizedBox( - // width: 38, - // height: 38, - // child: IconButton( - // onPressed: () {}, - // icon: const Icon( - // Icons.hd_outlined, - // size: 20, - // ), - // ), - // ), - // SizedBox( - // width: 38, - // height: 38, - // child: IconButton( - // onPressed: () => _liveRoomController - // .setVolumn(plPlayerController!.volume.value), - // icon: Obx(() => Icon( - // _liveRoomController.volumeOff.value - // ? Icons.volume_off_outlined - // : Icons.volume_up_outlined, - // size: 21, - // )), - // ), - // ), - // SizedBox( - // width: 38, - // height: 38, - // child: IconButton( - // onPressed: () => {}, - // // plPlayerController!.goToFullscreen(context), - // icon: const Icon( - // Icons.fullscreen, - // ), - // ), - // ), - // ]), - // ), ], ), ); + Widget childWhenEnabled = AspectRatio( + aspectRatio: 16 / 9, + child: plPlayerController!.videoPlayerController != null + ? PLVideoPlayer( + controller: plPlayerController!, + bottomControl: BottomControl( + controller: plPlayerController, + liveRoomCtr: _liveRoomController, + ), + ) + : const SizedBox(), + ); + if (Platform.isAndroid) { + return PiPSwitcher( + childWhenDisabled: childWhenDisabled, + childWhenEnabled: childWhenEnabled, + ); + } else { + return childWhenDisabled; + } } } diff --git a/lib/pages/liveRoom/widgets/bottom_control.dart b/lib/pages/liveRoom/widgets/bottom_control.dart new file mode 100644 index 00000000..49343bb1 --- /dev/null +++ b/lib/pages/liveRoom/widgets/bottom_control.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:floating/floating.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/models/video/play/url.dart'; +import 'package:pilipala/pages/liveRoom/index.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/utils/storage.dart'; + +class BottomControl extends StatefulWidget implements PreferredSizeWidget { + final PlPlayerController? controller; + final LiveRoomController? liveRoomCtr; + final Floating? floating; + const BottomControl({ + this.controller, + this.liveRoomCtr, + this.floating, + Key? key, + }) : super(key: key); + + @override + State createState() => _BottomControlState(); + + @override + Size get preferredSize => throw UnimplementedError(); +} + +class _BottomControlState extends State { + late PlayUrlModel videoInfo; + List playSpeed = PlaySpeed.values; + TextStyle subTitleStyle = const TextStyle(fontSize: 12); + TextStyle titleStyle = const TextStyle(fontSize: 14); + Size get preferredSize => const Size(double.infinity, kToolbarHeight); + Box localCache = GStrorage.localCache; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + const textStyle = TextStyle( + color: Colors.white, + fontSize: 12, + ); + return AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + primary: false, + centerTitle: false, + automaticallyImplyLeading: false, + titleSpacing: 14, + title: Row( + children: [ + // ComBtn( + // icon: const Icon( + // Icons.subtitles_outlined, + // size: 18, + // color: Colors.white, + // ), + // fuc: () => Get.back(), + // ), + const Spacer(), + // ComBtn( + // icon: const Icon( + // Icons.hd_outlined, + // size: 18, + // color: Colors.white, + // ), + // fuc: () => {}, + // ), + // const SizedBox(width: 4), + // Obx( + // () => ComBtn( + // icon: Icon( + // widget.liveRoomCtr!.volumeOff.value + // ? Icons.volume_off_outlined + // : Icons.volume_up_outlined, + // size: 18, + // color: Colors.white, + // ), + // fuc: () => {}, + // ), + // ), + // const SizedBox(width: 4), + if (Platform.isAndroid) ...[ + SizedBox( + width: 34, + height: 34, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () async { + bool canUsePiP = false; + widget.controller!.hiddenControls(false); + try { + canUsePiP = await widget.floating!.isPipAvailable; + } on PlatformException catch (_) { + canUsePiP = false; + } + if (canUsePiP) { + await widget.floating!.enable(); + } else {} + }, + icon: const Icon( + Icons.picture_in_picture_outlined, + size: 18, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 4), + ], + ComBtn( + icon: const Icon( + Icons.fullscreen, + size: 20, + color: Colors.white, + ), + fuc: () => widget.controller!.triggerFullScreen(), + ), + ], + ), + ); + } +} + +class MSliderTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + SliderThemeData? sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + const double trackHeight = 3; + final double trackLeft = offset.dx; + final double trackTop = + offset.dy + (parentBox.size.height - trackHeight) / 2 + 4; + final double trackWidth = parentBox.size.width; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } +} diff --git a/lib/pages/login/controller.dart b/lib/pages/login/controller.dart new file mode 100644 index 00000000..c002fdf9 --- /dev/null +++ b/lib/pages/login/controller.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/login.dart'; +import 'package:gt3_flutter_plugin/gt3_flutter_plugin.dart'; +import 'package:pilipala/models/login/index.dart'; + +class LoginPageController extends GetxController { + final GlobalKey mobFormKey = GlobalKey(); + final GlobalKey passwordFormKey = GlobalKey(); + final GlobalKey msgCodeFormKey = GlobalKey(); + + final TextEditingController mobTextController = TextEditingController(); + final TextEditingController passwordTextController = TextEditingController(); + final TextEditingController msgCodeTextController = TextEditingController(); + + final FocusNode mobTextFieldNode = FocusNode(); + final FocusNode passwordTextFieldNode = FocusNode(); + final FocusNode msgCodeTextFieldNode = FocusNode(); + + final PageController pageViewController = PageController(); + + RxInt currentIndex = 0.obs; + + final Gt3FlutterPlugin captcha = Gt3FlutterPlugin(); + + // 默认密码登录 + RxInt loginType = 0.obs; + + // 监听pageView切换 + void onPageChange(int index) { + currentIndex.value = index; + } + + // 输入手机号 下一页 + void nextStep() async { + if ((mobFormKey.currentState as FormState).validate()) { + await pageViewController.animateToPage( + 1, + duration: const Duration(microseconds: 3000), + curve: Curves.easeInOut, + ); + passwordTextFieldNode.requestFocus(); + } + } + + // 上一页 + void previousPage() async { + passwordTextFieldNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 200)); + pageViewController.animateToPage( + 0, + duration: const Duration(microseconds: 300), + curve: Curves.easeInOut, + ); + } + + // 切换登录方式 + void changeLoginType() { + loginType.value = loginType.value == 0 ? 1 : 0; + if (loginType.value == 0) { + passwordTextFieldNode.requestFocus(); + } else { + msgCodeTextFieldNode.requestFocus(); + } + } + + // app端密码登录 + void loginInByAppPassword() async { + if ((passwordFormKey.currentState as FormState).validate()) { + var webKeyRes = await LoginHttp.getWebKey(); + if (webKeyRes['status']) { + String rhash = webKeyRes['data']['hash']; + String key = webKeyRes['data']['key']; + LoginHttp.loginInByMobPwd( + tel: mobTextController.text, + password: passwordTextController.text, + key: key, + rhash: rhash, + ); + } else { + SmartDialog.showToast(webKeyRes['msg']); + } + } + } + + // 验证码登录 + void loginInByCode() { + if ((msgCodeFormKey.currentState as FormState).validate()) {} + } + + // app端验证码 + void getMsgCode() async { + getCaptcha((data) async { + CaptchaDataModel captchaData = data; + var res = await LoginHttp.sendAppSmsCode( + cid: 86, + tel: 13734077064, + token: captchaData.token!, + challenge: captchaData.geetest!.challenge!, + validate: captchaData.validate!, + seccode: captchaData.seccode!, + ); + print(res); + }); + } + + // 申请极验验证码 + Future getCaptcha(oncall) async { + SmartDialog.showLoading(msg: '请求中...'); + var result = await LoginHttp.queryCaptcha(); + if (result['status']) { + CaptchaDataModel captchaData = result['data']; + var registerData = Gt3RegisterData( + challenge: captchaData.geetest!.challenge, + gt: captchaData.geetest!.gt!, + success: true, + ); + captcha.addEventHandler(onShow: (Map message) async { + SmartDialog.dismiss(); + }, onClose: (Map message) async { + SmartDialog.showToast('关闭验证'); + }, onResult: (Map message) async { + debugPrint("Captcha result: $message"); + String code = message["code"]; + if (code == "1") { + // 发送 message["result"] 中的数据向 B 端的业务服务接口进行查询 + SmartDialog.showToast('验证成功'); + captchaData.validate = message['result']['geetest_validate']; + captchaData.seccode = message['result']['geetest_seccode']; + captchaData.geetest!.challenge = + message['result']['geetest_challenge']; + oncall(captchaData); + } else { + // 终端用户完成验证失败,自动重试 If the verification fails, it will be automatically retried. + debugPrint("Captcha result code : $code"); + } + }, onError: (Map message) async { + String code = message["code"]; + + // 处理验证中返回的错误 Handling errors returned in verification + if (Platform.isAndroid) { + // Android 平台 + if (code == "-2") { + // Dart 调用异常 Call exception + } else if (code == "-1") { + // Gt3RegisterData 参数不合法 Parameter is invalid + } else if (code == "201") { + // 网络无法访问 Network inaccessible + } else if (code == "202") { + // Json 解析错误 Analysis error + } else if (code == "204") { + // WebView 加载超时,请检查是否混淆极验 SDK Load timed out + } else if (code == "204_1") { + // WebView 加载前端页面错误,请查看日志 Error loading front-end page, please check the log + } else if (code == "204_2") { + // WebView 加载 SSLError + } else if (code == "206") { + // gettype 接口错误或返回为 null API error or return null + } else if (code == "207") { + // getphp 接口错误或返回为 null API error or return null + } else if (code == "208") { + // ajax 接口错误或返回为 null API error or return null + } else { + // 更多错误码参考开发文档 More error codes refer to the development document + // https://docs.geetest.com/sensebot/apirefer/errorcode/android + } + } + + if (Platform.isIOS) { + // iOS 平台 + if (code == "-1009") { + // 网络无法访问 Network inaccessible + } else if (code == "-1004") { + // 无法查找到 HOST Unable to find HOST + } else if (code == "-1002") { + // 非法的 URL Illegal URL + } else if (code == "-1001") { + // 网络超时 Network timeout + } else if (code == "-999") { + // 请求被意外中断, 一般由用户进行取消操作导致 The interrupted request was usually caused by the user cancelling the operation + } else if (code == "-21") { + // 使用了重复的 challenge Duplicate challenges are used + // 检查获取 challenge 是否进行了缓存 Check if the fetch challenge is cached + } else if (code == "-20") { + // 尝试过多, 重新引导用户触发验证即可 Try too many times, lead the user to request verification again + } else if (code == "-10") { + // 预判断时被封禁, 不会再进行图形验证 Banned during pre-judgment, and no more image captcha verification + } else if (code == "-2") { + // Dart 调用异常 Call exception + } else if (code == "-1") { + // Gt3RegisterData 参数不合法 Parameter is invalid + } else { + // 更多错误码参考开发文档 More error codes refer to the development document + // https://docs.geetest.com/sensebot/apirefer/errorcode/ios + } + } + }); + captcha.startCaptcha(registerData); + } else {} + } +} diff --git a/lib/pages/login/index.dart b/lib/pages/login/index.dart new file mode 100644 index 00000000..cdc05abd --- /dev/null +++ b/lib/pages/login/index.dart @@ -0,0 +1,4 @@ +library login; + +export './controller.dart'; +export 'view.dart'; diff --git a/lib/pages/login/view.dart b/lib/pages/login/view.dart new file mode 100644 index 00000000..6521e9d9 --- /dev/null +++ b/lib/pages/login/view.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'controller.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final LoginPageController _loginPageCtr = Get.put(LoginPageController()); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: Obx( + () => _loginPageCtr.currentIndex.value == 0 + ? IconButton( + onPressed: () async { + _loginPageCtr.mobTextFieldNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 200)); + Get.back(); + }, + icon: const Icon(Icons.close_outlined), + ) + : IconButton( + onPressed: () => _loginPageCtr.previousPage(), + icon: const Icon(Icons.arrow_back), + ), + ), + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: _loginPageCtr.pageViewController, + onPageChanged: (int index) => _loginPageCtr.onPageChange(index), + children: [ + Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 10, + bottom: MediaQuery.of(context).padding.bottom + 10, + ), + child: Form( + key: _loginPageCtr.mobFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + '登录', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + letterSpacing: 1, + height: 2.1, + fontSize: 34, + fontWeight: FontWeight.w500), + ), + Row( + children: [ + Text( + '请使用您的 BiliBili 账号登录。', + style: Theme.of(context).textTheme.titleSmall!, + ), + GestureDetector( + onTap: () {}, + child: const Icon(Icons.info_outline, size: 16), + ) + ], + ), + Container( + margin: const EdgeInsets.only(top: 38, bottom: 15), + child: TextFormField( + controller: _loginPageCtr.mobTextController, + focusNode: _loginPageCtr.mobTextFieldNode, + keyboardType: TextInputType.number, + decoration: InputDecoration( + isDense: true, + labelText: '输入手机号码', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6.0), + ), + ), + // 校验用户名 + validator: (v) { + return v!.trim().isNotEmpty ? null : "手机号码不能为空"; + }, + onSaved: (val) { + print(val); + }, + onEditingComplete: () { + _loginPageCtr.nextStep(); + }, + ), + ), + GestureDetector( + onTap: () { + Get.offNamed( + '/webview', + parameters: { + 'url': + 'https://passport.bilibili.com/h5-app/passport/login', + 'type': 'login', + 'pageTitle': '登录bilibili', + }, + ); + }, + child: Padding( + padding: const EdgeInsets.only(left: 2), + child: Text( + '使用网页端登录', + style: TextStyle( + color: Theme.of(context).colorScheme.primary), + ), + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton(onPressed: () {}, child: const Text('中国大陆')), + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + foregroundColor: + Theme.of(context).colorScheme.onPrimary, + backgroundColor: + Theme.of(context).colorScheme.primary, // 设置按钮背景色 + ), + onPressed: () => _loginPageCtr.nextStep(), + child: const Text('下一步'), + ) + ], + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 10, + bottom: MediaQuery.of(context).padding.bottom + 10, + ), + child: Obx( + () => _loginPageCtr.loginType.value == 0 + ? Form( + key: _loginPageCtr.passwordFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Row( + children: [ + Text( + '密码登录', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith( + letterSpacing: 1, + height: 2.1, + fontSize: 34, + fontWeight: FontWeight.w500), + ), + const SizedBox(width: 4), + IconButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.resolveWith( + (states) { + return Theme.of(context) + .colorScheme + .primary + .withOpacity(0.1); + }), + ), + onPressed: () => + _loginPageCtr.changeLoginType(), + icon: const Icon(Icons.swap_vert_outlined), + ) + ], + ), + Text( + '请输入您的 BiliBili 密码。', + style: Theme.of(context).textTheme.titleSmall!, + ), + Container( + margin: const EdgeInsets.only(top: 38, bottom: 15), + child: TextFormField( + controller: _loginPageCtr.passwordTextController, + focusNode: _loginPageCtr.passwordTextFieldNode, + keyboardType: TextInputType.visiblePassword, + decoration: InputDecoration( + isDense: true, + labelText: '输入密码', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6.0), + ), + ), + // 校验用户名 + validator: (v) { + return v!.trim().isNotEmpty ? null : "密码不能为空"; + }, + onSaved: (val) { + print(val); + }, + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => _loginPageCtr.previousPage(), + child: const Text('上一步'), + ), + const SizedBox(width: 15), + TextButton( + style: TextButton.styleFrom( + padding: + const EdgeInsets.fromLTRB(20, 0, 20, 0), + foregroundColor: + Theme.of(context).colorScheme.onPrimary, + backgroundColor: Theme.of(context) + .colorScheme + .primary, // 设置按钮背景色 + ), + onPressed: () => + _loginPageCtr.loginInByAppPassword(), + child: const Text('确认登录'), + ) + ], + ), + ], + ), + ) + : Form( + key: _loginPageCtr.msgCodeFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Row( + children: [ + Text( + '验证码登录', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith( + letterSpacing: 1, + height: 2.1, + fontSize: 34, + fontWeight: FontWeight.w500), + ), + const SizedBox(width: 4), + IconButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.resolveWith( + (states) { + return Theme.of(context) + .colorScheme + .primary + .withOpacity(0.1); + }), + ), + onPressed: () => + _loginPageCtr.changeLoginType(), + icon: const Icon(Icons.swap_vert_outlined), + ) + ], + ), + Text( + '请输入收到到验证码。', + style: Theme.of(context).textTheme.titleSmall!, + ), + Container( + margin: const EdgeInsets.only(top: 38, bottom: 15), + child: Stack( + children: [ + TextFormField( + controller: + _loginPageCtr.msgCodeTextController, + focusNode: _loginPageCtr.msgCodeTextFieldNode, + maxLength: 6, + keyboardType: TextInputType.number, + decoration: InputDecoration( + isDense: true, + labelText: '输入验证码', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6.0), + ), + ), + // 校验用户名 + validator: (v) { + return v!.trim().isNotEmpty + ? null + : "验证码不能为空"; + }, + onSaved: (val) { + print(val); + }, + ), + Positioned( + right: 8, + top: 4, + child: Center( + child: TextButton( + onPressed: () => + _loginPageCtr.getMsgCode(), + child: const Text('获取验证码'), + ), + ), + ), + ], + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => _loginPageCtr.previousPage(), + child: const Text('上一步'), + ), + const SizedBox(width: 15), + TextButton( + style: TextButton.styleFrom( + padding: + const EdgeInsets.fromLTRB(20, 0, 20, 0), + foregroundColor: + Theme.of(context).colorScheme.onPrimary, + backgroundColor: Theme.of(context) + .colorScheme + .primary, // 设置按钮背景色 + ), + onPressed: () => _loginPageCtr.loginInByCode(), + child: const Text('确认登录'), + ) + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/main/view.dart b/lib/pages/main/view.dart index c744098e..ee8d3829 100644 --- a/lib/pages/main/view.dart +++ b/lib/pages/main/view.dart @@ -29,6 +29,8 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { late Animation? _slideAnimation; int selectedIndex = 0; int? _lastSelectTime; //上次点击时间 + Box setting = GStrorage.setting; + late bool enableMYBar; @override void initState() { @@ -45,6 +47,7 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { Tween(begin: 0.8, end: 1.0).animate(_animationController!); _lastSelectTime = DateTime.now().millisecondsSinceEpoch; _pageController = PageController(initialPage: selectedIndex); + enableMYBar = setting.get(SettingBoxKey.enableMYBar, defaultValue: true); } void setIndex(int value) async { @@ -144,21 +147,38 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { builder: (context, AsyncSnapshot snapshot) { return AnimatedSlide( curve: Curves.easeInOutCubicEmphasized, - duration: const Duration(milliseconds: 1000), + duration: const Duration(milliseconds: 500), offset: Offset(0, snapshot.data ? 0 : 1), - child: NavigationBar( - onDestinationSelected: (value) => setIndex(value), - selectedIndex: selectedIndex, - destinations: [ - ..._mainController.navigationBars.map((e) { - return NavigationDestination( - icon: e['icon'], - selectedIcon: e['selectIcon'], - label: e['label'], - ); - }).toList(), - ], - ), + child: enableMYBar + ? NavigationBar( + onDestinationSelected: (value) => setIndex(value), + selectedIndex: selectedIndex, + destinations: [ + ..._mainController.navigationBars.map((e) { + return NavigationDestination( + icon: e['icon'], + selectedIcon: e['selectIcon'], + label: e['label'], + ); + }).toList(), + ], + ) + : BottomNavigationBar( + currentIndex: selectedIndex, + onTap: (value) => setIndex(value), + iconSize: 16, + selectedFontSize: 12, + unselectedFontSize: 12, + items: [ + ..._mainController.navigationBars.map((e) { + return BottomNavigationBarItem( + icon: e['icon'], + activeIcon: e['selectIcon'], + label: e['label'], + ); + }).toList(), + ], + ), ); }, ), diff --git a/lib/pages/media/view.dart b/lib/pages/media/view.dart index 8ce33597..c621c2dc 100644 --- a/lib/pages/media/view.dart +++ b/lib/pages/media/view.dart @@ -39,45 +39,47 @@ class _MediaPageState extends State Color primary = Theme.of(context).colorScheme.primary; return Scaffold( appBar: AppBar(toolbarHeight: 30), - body: Column( - children: [ - ListTile( - leading: null, - title: Padding( - padding: const EdgeInsets.only(left: 20), - child: Text( - '媒体库', - style: TextStyle( - fontSize: Theme.of(context).textTheme.titleLarge!.fontSize, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - for (var i in mediaController.list) ...[ + body: SingleChildScrollView( + child: Column( + children: [ ListTile( - onTap: () => i['onTap'](), - dense: true, - leading: Padding( - padding: const EdgeInsets.only(left: 15), - child: Icon( - i['icon'], - color: primary, + leading: null, + title: Padding( + padding: const EdgeInsets.only(left: 20), + child: Text( + '媒体库', + style: TextStyle( + fontSize: Theme.of(context).textTheme.titleLarge!.fontSize, + fontWeight: FontWeight.bold, + ), ), ), - contentPadding: - const EdgeInsets.only(left: 15, top: 2, bottom: 2), - minLeadingWidth: 0, - title: Text( - i['title'], - style: const TextStyle(fontSize: 15), - ), ), + for (var i in mediaController.list) ...[ + ListTile( + onTap: () => i['onTap'](), + dense: true, + leading: Padding( + padding: const EdgeInsets.only(left: 15), + child: Icon( + i['icon'], + color: primary, + ), + ), + contentPadding: + const EdgeInsets.only(left: 15, top: 2, bottom: 2), + minLeadingWidth: 0, + title: Text( + i['title'], + style: const TextStyle(fontSize: 15), + ), + ), + ], + Obx(() => mediaController.userLogin.value + ? favFolder(mediaController, context) + : const SizedBox()) ], - Obx(() => mediaController.userLogin.value - ? favFolder(mediaController, context) - : const SizedBox()) - ], + ), ), ); } @@ -136,11 +138,14 @@ class _MediaPageState extends State // const SizedBox(height: 10), SizedBox( width: double.infinity, - height: 170 * MediaQuery.of(context).textScaleFactor, + height: 200 * MediaQuery.of(context).textScaleFactor, child: FutureBuilder( future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SizedBox(); + } Map data = snapshot.data as Map; if (data['status']) { List favFolderList = diff --git a/lib/pages/member/archive/controller.dart b/lib/pages/member/archive/controller.dart index e893a07d..3fc90328 100644 --- a/lib/pages/member/archive/controller.dart +++ b/lib/pages/member/archive/controller.dart @@ -2,23 +2,43 @@ import 'package:get/get.dart'; import 'package:pilipala/http/member.dart'; class ArchiveController extends GetxController { + ArchiveController(this.mid); int? mid; int pn = 1; int count = 0; + RxMap currentOrder = {}.obs; + List> orderList = [ + {'type': 'pubdate', 'label': '最新发布'}, + {'type': 'click', 'label': '最多播放'}, + {'type': 'stow', 'label': '最多收藏'}, + ]; @override void onInit() { super.onInit(); - mid = int.parse(Get.parameters['mid']!); + mid ??= int.parse(Get.parameters['mid']!); + print('🐶🐶: $mid'); + currentOrder.value = orderList.first; } // 获取用户投稿 Future getMemberArchive() async { - var res = await MemberHttp.memberArchive(mid: mid, pn: pn); + var res = await MemberHttp.memberArchive( + mid: mid, pn: pn, order: currentOrder['type']!); if (res['status']) { count = res['data'].page['count']; pn += 1; } return res; } + + toggleSort() async { + pn = 1; + int index = orderList.indexOf(currentOrder.value); + if (index == orderList.length - 1) { + currentOrder.value = orderList.first; + } else { + currentOrder.value = orderList[index + 1]; + } + } } diff --git a/lib/pages/member/archive/view.dart b/lib/pages/member/archive/view.dart index 430f5ede..f34a396c 100644 --- a/lib/pages/member/archive/view.dart +++ b/lib/pages/member/archive/view.dart @@ -5,10 +5,12 @@ import 'package:loading_more_list/loading_more_list.dart'; import 'package:pilipala/common/widgets/video_card_h.dart'; import 'package:pilipala/models/member/archive.dart'; import 'package:pilipala/pages/member/archive/index.dart'; +import 'package:pilipala/utils/utils.dart'; import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; class ArchivePanel extends StatefulWidget { - const ArchivePanel({super.key}); + final int? mid; + const ArchivePanel({super.key, this.mid}); @override State createState() => _ArchivePanelState(); @@ -17,11 +19,21 @@ class ArchivePanel extends StatefulWidget { class _ArchivePanelState extends State with AutomaticKeepAliveClientMixin { DateTime lastRefreshTime = DateTime.now(); - late final LoadMoreListSource source = LoadMoreListSource(); + late final LoadMoreListSource source; + late final ArchiveController _archiveController; @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + print('🐶🐶: ${widget.mid}'); + _archiveController = Get.put(ArchiveController(widget.mid), + tag: Utils.makeHeroTag(widget.mid)); + source = LoadMoreListSource(_archiveController); + } + @override Widget build(BuildContext context) { super.build(context); @@ -40,14 +52,63 @@ class _ArchivePanelState extends State // return PullToRefreshHeader(info, lastRefreshTime); // }, // ), - const SizedBox(height: 4), + Padding( + padding: + const EdgeInsets.only(left: 14, top: 8, bottom: 8, right: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('排序方式'), + SizedBox( + height: 35, + width: 85, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + // _archiveController.order = 'click'; + // _archiveController.pn = 1; + _archiveController.toggleSort(); + source.refresh(true); + // LoadMoreListSource().loadData(); + }, + child: Obx( + () => AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: + (Widget child, Animation animation) { + return ScaleTransition( + scale: animation, child: child); + }, + child: Text( + _archiveController.currentOrder['label']!, + key: ValueKey( + _archiveController.currentOrder['label']!), + ), + ), + ), + ), + ), + ], + ), + ), Expanded( child: LoadingMoreList( ListConfig( sourceList: source, itemBuilder: (BuildContext c, VListItemModel item, int index) { - return VideoCardH(videoItem: item); + if (index == 0) { + return Column( + children: [ + const SizedBox(height: 6), + VideoCardH(videoItem: item) + ], + ); + } else { + return VideoCardH(videoItem: item); + } }, indicatorBuilder: _buildIndicator, ), @@ -142,14 +203,18 @@ class _ArchivePanelState extends State } class LoadMoreListSource extends LoadingMoreBase { - final ArchiveController _archiveController = - Get.put(ArchiveController(), tag: Get.arguments['heroTag']); + late ArchiveController ctr; + LoadMoreListSource(this.ctr); + bool forceRefresh = false; @override Future loadData([bool isloadMoreAction = false]) async { bool isSuccess = false; - var res = await _archiveController.getMemberArchive(); + var res = await ctr.getMemberArchive(); if (res['status']) { + if (ctr.pn == 2) { + clear(); + } addAll(res['data'].list.vlist); } if (length < res['data'].page['count']) { @@ -159,4 +224,17 @@ class LoadMoreListSource extends LoadingMoreBase { } return isSuccess; } + + @override + Future refresh([bool clearBeforeRequest = false]) async { + // _hasMore = true; + // pageindex = 1; + // //force to refresh list when you don't want clear list before request + // //for the case, if your list already has 20 items. + forceRefresh = !clearBeforeRequest; + var result = await super.refresh(clearBeforeRequest); + + forceRefresh = false; + return result; + } } diff --git a/lib/pages/member/controller.dart b/lib/pages/member/controller.dart index db4deaae..0d40ec65 100644 --- a/lib/pages/member/controller.dart +++ b/lib/pages/member/controller.dart @@ -3,22 +3,26 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/http/member.dart'; +import 'package:pilipala/http/user.dart'; import 'package:pilipala/http/video.dart'; import 'package:pilipala/models/member/archive.dart'; import 'package:pilipala/models/member/info.dart'; import 'package:pilipala/utils/storage.dart'; +import 'package:share_plus/share_plus.dart'; class MemberController extends GetxController { late int mid; Rx memberInfo = MemberInfoModel().obs; Map? userStat; - String? face; + RxString face = ''.obs; String? heroTag; Box userInfoCache = GStrorage.userInfo; late int ownerMid; // 投稿列表 RxList? archiveList = [VListItemModel()].obs; var userInfo; + RxInt attribute = (-1).obs; + RxString attributeText = '关注'.obs; @override void onInit() { @@ -26,8 +30,9 @@ class MemberController extends GetxController { mid = int.parse(Get.parameters['mid']!); userInfo = userInfoCache.get('userInfoCache'); ownerMid = userInfo != null ? userInfo.mid : -1; - face = Get.arguments['face'] ?? ''; + face.value = Get.arguments['face'] ?? ''; heroTag = Get.arguments['heroTag'] ?? ''; + relationSearch(); } // 获取用户信息 @@ -36,6 +41,7 @@ class MemberController extends GetxController { var res = await MemberHttp.memberInfo(mid: mid); if (res['status']) { memberInfo.value = res['data']; + face.value = res['data'].face; } return res; } @@ -63,7 +69,10 @@ class MemberController extends GetxController { SmartDialog.showToast('账号未登录'); return; } - + if (attribute.value == 128) { + blockUser(); + return; + } SmartDialog.show( useSystem: true, animationType: SmartAnimationType.centerFade_otherSlide, @@ -73,8 +82,12 @@ class MemberController extends GetxController { content: Text(memberInfo.value.isFollowed! ? '取消关注UP主?' : '关注UP主?'), actions: [ TextButton( - onPressed: () => SmartDialog.dismiss(), - child: const Text('点错了')), + onPressed: () => SmartDialog.dismiss(), + child: Text( + '点错了', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), TextButton( onPressed: () async { await VideoHttp.relationMod( @@ -83,8 +96,7 @@ class MemberController extends GetxController { reSrc: 11, ); memberInfo.value.isFollowed = !memberInfo.value.isFollowed!; - SmartDialog.dismiss(); - SmartDialog.showLoading(); + relationSearch(); SmartDialog.dismiss(); memberInfo.update((val) {}); }, @@ -95,4 +107,70 @@ class MemberController extends GetxController { }, ); } + + // 关系查询 + Future relationSearch() async { + if (userInfo == null) return; + if (mid == ownerMid) return; + var res = await UserHttp.relationSearch(mid); + if (res['status']) { + attribute.value = res['data']['relation']['attribute']; + attributeText.value = attribute.value == 0 + ? '关注' + : attribute.value == 2 + ? '已关注' + : attribute.value == 6 + ? '已互粉' + : '已拉黑'; + } + } + + // 拉黑用户 + Future blockUser() async { + if (userInfo == null) { + SmartDialog.showToast('账号未登录'); + return; + } + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: Text(attribute.value != 128 ? '确定拉黑UP主?' : '从黑名单移除UP主'), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: Text( + '点错了', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () async { + var res = await VideoHttp.relationMod( + mid: mid, + act: attribute.value != 128 ? 5 : 6, + reSrc: 11, + ); + SmartDialog.dismiss(); + if (res['status']) { + attribute.value = attribute.value != 128 ? 128 : 0; + attributeText.value = attribute.value == 128 ? '已拉黑' : '关注'; + memberInfo.value.isFollowed = false; + relationSearch(); + memberInfo.update((val) {}); + } + }, + child: const Text('确认'), + ) + ], + ); + }, + ); + } + + void shareUser() { + Share.share('${memberInfo.value.name} - https://space.bilibili.com/$mid'); + } } diff --git a/lib/pages/member/dynamic/controller.dart b/lib/pages/member/dynamic/controller.dart index ff45e056..056240ad 100644 --- a/lib/pages/member/dynamic/controller.dart +++ b/lib/pages/member/dynamic/controller.dart @@ -2,23 +2,29 @@ import 'package:get/get.dart'; import 'package:pilipala/http/member.dart'; class MemberDynamicPanelController extends GetxController { + MemberDynamicPanelController(this.mid); int? mid; String offset = ''; int count = 0; + bool hasMore = true; @override void onInit() { super.onInit(); - mid = int.parse(Get.parameters['mid']!); + mid ??= int.parse(Get.parameters['mid']!); } Future getMemberDynamic() async { + if (!hasMore) { + return {'status': false}; + } var res = await MemberHttp.memberDynamic( offset: offset, mid: mid, ); if (res['status']) { offset = res['data'].offset; + hasMore = res['data'].hasMore; } return res; } diff --git a/lib/pages/member/dynamic/view.dart b/lib/pages/member/dynamic/view.dart index 1c48baa9..15d7376e 100644 --- a/lib/pages/member/dynamic/view.dart +++ b/lib/pages/member/dynamic/view.dart @@ -4,11 +4,13 @@ import 'package:get/get.dart'; import 'package:loading_more_list/loading_more_list.dart'; import 'package:pilipala/models/dynamics/result.dart'; import 'package:pilipala/pages/dynamics/widgets/dynamic_panel.dart'; +import 'package:pilipala/utils/utils.dart'; import 'controller.dart'; class MemberDynamicPanel extends StatefulWidget { - const MemberDynamicPanel({super.key}); + final int? mid; + const MemberDynamicPanel({super.key, this.mid}); @override State createState() => _MemberDynamicPanelState(); @@ -17,11 +19,20 @@ class MemberDynamicPanel extends StatefulWidget { class _MemberDynamicPanelState extends State with AutomaticKeepAliveClientMixin { DateTime lastRefreshTime = DateTime.now(); - late final LoadMoreListSource source = LoadMoreListSource(); + late final LoadMoreListSource source; + late final MemberDynamicPanelController _dynamicController; @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + _dynamicController = Get.put(MemberDynamicPanelController(widget.mid), + tag: Utils.makeHeroTag(widget.mid)); + source = LoadMoreListSource(_dynamicController); + } + @override Widget build(BuildContext context) { super.build(context); @@ -118,21 +129,24 @@ class _MemberDynamicPanelState extends State } class LoadMoreListSource extends LoadingMoreBase { - final _dynamicController = - Get.put(MemberDynamicPanelController(), tag: Get.arguments['heroTag']); + late MemberDynamicPanelController ctr; + LoadMoreListSource(this.ctr); @override Future loadData([bool isloadMoreAction = false]) async { bool isSuccess = false; - var res = await _dynamicController.getMemberDynamic(); + var res = await ctr.getMemberDynamic(); if (res['status']) { addAll(res['data'].items); } - if (res['data'].hasMore) { - isSuccess = true; - } else { - isSuccess = false; - } + try { + if (res['data'].hasMore) { + isSuccess = true; + } else { + isSuccess = false; + } + } catch (_) {} + return isSuccess; } } diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index 55dad4f0..6fb8e228 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -8,6 +8,7 @@ import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/pages/member/archive/view.dart'; import 'package:pilipala/pages/member/dynamic/index.dart'; import 'package:pilipala/pages/member/index.dart'; +import 'package:pilipala/utils/utils.dart'; import 'widgets/profile.dart'; @@ -20,21 +21,26 @@ class MemberPage extends StatefulWidget { class _MemberPageState extends State with SingleTickerProviderStateMixin { - final MemberController _memberController = Get.put(MemberController()); + late String heroTag; + late MemberController _memberController; Future? _futureBuilderFuture; final ScrollController _extendNestCtr = ScrollController(); late TabController _tabController; final StreamController appbarStream = StreamController(); + late int mid; @override void initState() { super.initState(); + mid = int.parse(Get.parameters['mid']!); + heroTag = Get.arguments['heroTag'] ?? Utils.makeHeroTag(mid); + _memberController = Get.put(MemberController(), tag: heroTag); _tabController = TabController(length: 3, vsync: this, initialIndex: 2); _futureBuilderFuture = _memberController.getInfo(); _extendNestCtr.addListener( () { double offset = _extendNestCtr.position.pixels; - if (offset > 250) { + if (offset > 230) { appbarStream.add(true); } else { appbarStream.add(false); @@ -63,7 +69,7 @@ class _MemberPageState extends State elevation: 0, scrolledUnderElevation: 1, forceElevated: innerBoxIsScrolled, - expandedHeight: 320, + expandedHeight: 290, titleSpacing: 0, title: StreamBuilder( stream: appbarStream.stream, @@ -77,11 +83,13 @@ class _MemberPageState extends State children: [ Row( children: [ - NetworkImgLayer( - width: 35, - height: 35, - type: 'avatar', - src: _memberController.face ?? '', + Obx( + () => NetworkImgLayer( + width: 35, + height: 35, + type: 'avatar', + src: _memberController.face.value, + ), ), const SizedBox(width: 10), Obx( @@ -102,40 +110,83 @@ class _MemberPageState extends State }, ), actions: [ - IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)), + IconButton( + onPressed: () => Get.toNamed( + '/memberSearch?mid=${Get.parameters['mid']}&uname=${_memberController.memberInfo.value.name!}'), + icon: const Icon(Icons.search_outlined), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (BuildContext context) => [ + if (_memberController.ownerMid != + _memberController.mid) ...[ + PopupMenuItem( + onTap: () => _memberController.blockUser(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.block, size: 19), + const SizedBox(width: 10), + Text(_memberController.attribute.value != 128 + ? '加入黑名单' + : '移除黑名单'), + ], + ), + ) + ], + PopupMenuItem( + onTap: () => _memberController.shareUser(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.share_outlined, size: 19), + const SizedBox(width: 10), + Text(_memberController.ownerMid != + _memberController.mid + ? '分享UP主' + : '分享我的主页'), + ], + ), + ), + ], + ), const SizedBox(width: 4), ], flexibleSpace: FlexibleSpaceBar( background: Stack( children: [ - if (_memberController.face != null) - Positioned.fill( - bottom: 10, - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - fit: BoxFit.fitWidth, - image: NetworkImage(_memberController.face!), - alignment: Alignment.topCenter, - isAntiAlias: true, - ), - ), - foregroundDecoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context) - .colorScheme - .background - .withOpacity(0.44), - Theme.of(context).colorScheme.background, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.0, 0.46], - ), - ), - ), - ), + Obx( + () => _memberController.face.value != '' + ? Positioned.fill( + bottom: 10, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.fitWidth, + image: NetworkImage( + _memberController.face.value), + alignment: Alignment.topCenter, + isAntiAlias: true, + ), + ), + foregroundDecoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context) + .colorScheme + .background + .withOpacity(0.44), + Theme.of(context).colorScheme.background, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.46], + ), + ), + ), + ) + : const SizedBox(), + ), Positioned( left: 0, right: 0, @@ -145,159 +196,7 @@ class _MemberPageState extends State color: Theme.of(context).colorScheme.background, ), ), - Padding( - padding: const EdgeInsets.only(left: 18, right: 18), - child: FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.done) { - Map data = snapshot.data!; - if (data['status']) { - return Obx( - () => Stack( - alignment: AlignmentDirectional.center, - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - profile(_memberController), - const SizedBox(height: 14), - Row( - children: [ - Flexible( - child: Text( - _memberController - .memberInfo.value.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - fontWeight: - FontWeight.bold), - )), - const SizedBox(width: 2), - if (_memberController - .memberInfo.value.sex == - '女') - const Icon( - FontAwesomeIcons.venus, - size: 14, - color: Colors.pink, - ), - if (_memberController - .memberInfo.value.sex == - '男') - const Icon( - FontAwesomeIcons.mars, - size: 14, - color: Colors.blue, - ), - const SizedBox(width: 4), - Image.asset( - 'assets/images/lv/lv${_memberController.memberInfo.value.level}.png', - height: 11, - ), - const SizedBox(width: 6), - if (_memberController.memberInfo - .value.vip!.status == - 1 && - _memberController.memberInfo - .value.vip!.label![ - 'img_label_uri_hans'] != - '') ...[ - Image.network( - _memberController.memberInfo - .value.vip!.label![ - 'img_label_uri_hans'], - height: 20, - ), - ] else if (_memberController - .memberInfo - .value - .vip! - .status == - 1 && - _memberController.memberInfo - .value.vip!.label![ - 'img_label_uri_hans_static'] != - '') ...[ - Image.network( - _memberController.memberInfo - .value.vip!.label![ - 'img_label_uri_hans_static'], - height: 20, - ), - ] - ], - ), - if (_memberController.memberInfo.value - .official!['title'] != - '') ...[ - const SizedBox(height: 6), - Text.rich( - maxLines: 2, - TextSpan( - text: _memberController - .memberInfo - .value - .official!['role'] == - 1 - ? '个人认证:' - : '企业认证:', - style: TextStyle( - color: Theme.of(context) - .primaryColor, - ), - children: [ - TextSpan( - text: _memberController - .memberInfo - .value - .official!['title'], - ), - ], - ), - softWrap: true, - ), - ], - const SizedBox(height: 4), - if (_memberController - .memberInfo.value.sign != - '') - SelectableRegion( - magnifierConfiguration: - const TextMagnifierConfiguration(), - focusNode: FocusNode(), - selectionControls: - MaterialTextSelectionControls(), - child: Text( - _memberController - .memberInfo.value.sign!, - textAlign: TextAlign.left, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ); - } else { - return const SizedBox(); - } - } else { - // 骨架屏 - return profile(_memberController, - loadingStatus: true); - } - }, - ), - ) + profileWidget(), ], ), ), @@ -322,10 +221,10 @@ class _MemberPageState extends State Expanded( child: TabBarView( controller: _tabController, - children: const [ - Text('主页'), - MemberDynamicPanel(), - ArchivePanel(), + children: [ + const Text('主页'), + MemberDynamicPanel(mid: mid), + ArchivePanel(mid: mid), ], )) ], @@ -333,4 +232,143 @@ class _MemberPageState extends State ), ); } + + Widget profileWidget() { + return Padding( + padding: const EdgeInsets.only(left: 18, right: 18), + child: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data!; + if (data['status']) { + return Obx( + () => Stack( + alignment: AlignmentDirectional.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + profile(_memberController), + const SizedBox(height: 14), + Row( + children: [ + Flexible( + child: Text( + _memberController.memberInfo.value.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(fontWeight: FontWeight.bold), + )), + const SizedBox(width: 2), + if (_memberController.memberInfo.value.sex == '女') + const Icon( + FontAwesomeIcons.venus, + size: 14, + color: Colors.pink, + ), + if (_memberController.memberInfo.value.sex == '男') + const Icon( + FontAwesomeIcons.mars, + size: 14, + color: Colors.blue, + ), + const SizedBox(width: 4), + Image.asset( + 'assets/images/lv/lv${_memberController.memberInfo.value.level}.png', + height: 11, + ), + const SizedBox(width: 6), + if (_memberController + .memberInfo.value.vip!.status == + 1 && + _memberController.memberInfo.value.vip! + .label!['img_label_uri_hans'] != + '') ...[ + Image.network( + _memberController.memberInfo.value.vip! + .label!['img_label_uri_hans'], + height: 20, + ), + ] else if (_memberController + .memberInfo.value.vip!.status == + 1 && + _memberController.memberInfo.value.vip! + .label!['img_label_uri_hans_static'] != + '') ...[ + Image.network( + _memberController.memberInfo.value.vip! + .label!['img_label_uri_hans_static'], + height: 20, + ), + ] + ], + ), + if (_memberController + .memberInfo.value.official!['title'] != + '') ...[ + const SizedBox(height: 6), + Text.rich( + maxLines: 2, + TextSpan( + text: _memberController + .memberInfo.value.official!['role'] == + 1 + ? '个人认证:' + : '企业认证:', + style: TextStyle( + color: Theme.of(context).primaryColor, + ), + children: [ + TextSpan( + text: _memberController + .memberInfo.value.official!['title'], + ), + ], + ), + softWrap: true, + ), + ], + const SizedBox(height: 4), + if (_memberController.memberInfo.value.sign != '') + SelectableText( + _memberController.memberInfo.value.sign!, + maxLines: _memberController + .memberInfo.value.official!['title'] != + '' + ? 1 + : 2, + style: const TextStyle( + overflow: TextOverflow.ellipsis), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: SelectableText(_memberController + .memberInfo.value.sign!), + ); + }, + ); + }, + ) + ], + ), + ], + ), + ); + } else { + return const SizedBox(); + } + } else { + // 骨架屏 + return profile(_memberController, loadingStatus: true); + } + }, + ), + ); + } } diff --git a/lib/pages/member/widgets/profile.dart b/lib/pages/member/widgets/profile.dart index d8dc651a..22e88106 100644 --- a/lib/pages/member/widgets/profile.dart +++ b/lib/pages/member/widgets/profile.dart @@ -15,62 +15,63 @@ Widget profile(ctr, {loadingStatus = false}) { child: Row( children: [ Hero( - tag: ctr.heroTag!, - child: Stack( - children: [ - NetworkImgLayer( - width: 90, - height: 90, - type: 'avatar', - src: !loadingStatus ? memberInfo.face : ctr.face, - ), - if (!loadingStatus && - memberInfo.liveRoom != null && - memberInfo.liveRoom!.liveStatus == 1) - Positioned( - bottom: 0, - left: 14, - child: GestureDetector( - onTap: () { - LiveItemModel liveItem = LiveItemModel.fromJson({ - 'title': memberInfo.liveRoom!.title, - 'uname': memberInfo.name, - 'face': memberInfo.face, - 'roomid': memberInfo.liveRoom!.roomId, - 'watched_show': memberInfo.liveRoom!.watchedShow, - }); - Get.toNamed( - '/liveRoom?roomid=${memberInfo.liveRoom!.roomId}', - arguments: {'liveItem': liveItem}, - ); - }, - child: Container( - padding: const EdgeInsets.fromLTRB(6, 2, 6, 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: - const BorderRadius.all(Radius.circular(10)), - ), - child: Row(children: [ - Image.asset( - 'assets/images/live.gif', - height: 10, - ), - Text( - ' 直播中', - style: TextStyle( - color: Colors.white, - fontSize: Theme.of(context) - .textTheme - .labelSmall! - .fontSize), - ) - ]), + tag: ctr.heroTag!, + child: Stack( + children: [ + NetworkImgLayer( + width: 90, + height: 90, + type: 'avatar', + src: !loadingStatus ? memberInfo.face : ctr.face.value, + ), + if (!loadingStatus && + memberInfo.liveRoom != null && + memberInfo.liveRoom!.liveStatus == 1) + Positioned( + bottom: 0, + left: 14, + child: GestureDetector( + onTap: () { + LiveItemModel liveItem = LiveItemModel.fromJson({ + 'title': memberInfo.liveRoom!.title, + 'uname': memberInfo.name, + 'face': memberInfo.face, + 'roomid': memberInfo.liveRoom!.roomId, + 'watched_show': memberInfo.liveRoom!.watchedShow, + }); + Get.toNamed( + '/liveRoom?roomid=${memberInfo.liveRoom!.roomId}', + arguments: {'liveItem': liveItem}, + ); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(6, 2, 6, 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: + const BorderRadius.all(Radius.circular(10)), ), + child: Row(children: [ + Image.asset( + 'assets/images/live.gif', + height: 10, + ), + Text( + ' 直播中', + style: TextStyle( + color: Colors.white, + fontSize: Theme.of(context) + .textTheme + .labelSmall! + .fontSize), + ) + ]), ), - ) - ], - )), + ), + ) + ], + ), + ), const SizedBox(width: 12), Expanded( child: Column( @@ -122,12 +123,14 @@ Widget profile(ctr, {loadingStatus = false}) { : '-', style: const TextStyle( fontWeight: FontWeight.bold)), - Text('粉丝', - style: TextStyle( - fontSize: Theme.of(context) - .textTheme - .labelMedium! - .fontSize)) + Text( + '粉丝', + style: TextStyle( + fontSize: Theme.of(context) + .textTheme + .labelMedium! + .fontSize), + ) ], ), ), @@ -152,34 +155,41 @@ Widget profile(ctr, {loadingStatus = false}) { if (ctr.ownerMid != ctr.mid) ...[ Row( children: [ - TextButton( - onPressed: () => ctr.actionRelationMod(), - style: TextButton.styleFrom( - padding: const EdgeInsets.only(left: 42, right: 42), - foregroundColor: - !loadingStatus && memberInfo.isFollowed! - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.onPrimary, - backgroundColor: !loadingStatus && - memberInfo.isFollowed! - ? Theme.of(context).colorScheme.onInverseSurface - : Theme.of(context) - .colorScheme - .primary, // 设置按钮背景色 + Obx( + () => Expanded( + child: TextButton( + onPressed: () => ctr.actionRelationMod(), + style: TextButton.styleFrom( + foregroundColor: ctr.attribute.value == -1 + ? Colors.transparent + : ctr.attribute.value != 0 + ? Theme.of(context).colorScheme.outline + : Theme.of(context) + .colorScheme + .onPrimary, + backgroundColor: ctr.attribute.value != 0 + ? Theme.of(context) + .colorScheme + .onInverseSurface + : Theme.of(context) + .colorScheme + .primary, // 设置按钮背景色 + ), + child: Obx(() => Text(ctr.attributeText.value)), + ), ), - child: Text(!loadingStatus && memberInfo.isFollowed! - ? '取关' - : '关注'), ), const SizedBox(width: 8), - TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - padding: const EdgeInsets.only(left: 42, right: 42), - backgroundColor: - Theme.of(context).colorScheme.onInverseSurface, + Expanded( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + backgroundColor: Theme.of(context) + .colorScheme + .onInverseSurface, + ), + child: const Text('发消息'), ), - child: const Text('发消息'), ) ], ) diff --git a/lib/pages/member_search/controller.dart b/lib/pages/member_search/controller.dart new file mode 100644 index 00000000..be4f5b1a --- /dev/null +++ b/lib/pages/member_search/controller.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/member.dart'; +import 'package:pilipala/models/member/archive.dart'; + +class MemberSearchController extends GetxController { + final ScrollController scrollController = ScrollController(); + Rx controller = TextEditingController().obs; + final FocusNode searchFocusNode = FocusNode(); + RxString searchKeyWord = ''.obs; + String hintText = '搜索'; + RxString loadingStatus = 'init'.obs; + RxString loadingText = '加载中...'.obs; + bool hasRequest = false; + late int mid; + RxString uname = ''.obs; + int archivePn = 1; + int archiveCount = 0; + RxList archiveList = [].obs; + int dynamic_pn = 1; + RxList dynamicList = [].obs; + + int ps = 30; + + @override + void onInit() { + super.onInit(); + mid = int.parse(Get.parameters['mid']!); + uname.value = Get.parameters['uname']!; + } + + // 清空搜索 + void onClear() { + if (searchKeyWord.value.isNotEmpty && controller.value.text != '') { + controller.value.clear(); + searchKeyWord.value = ''; + } else { + Get.back(); + } + } + + void onChange(value) { + searchKeyWord.value = value; + } + + // 提交搜索内容 + void submit() { + loadingStatus.value = 'loading'; + if (hasRequest) { + archivePn = 1; + searchArchives(); + } + } + + // 搜索视频 + Future searchArchives({type = 'init'}) async { + if (type == 'onLoad' && loadingText.value == '没有更多了') { + return; + } + var res = await MemberHttp.memberArchive( + mid: mid, + pn: archivePn, + keyword: controller.value.text, + order: 'pubdate', + ); + if (res['status']) { + if (type == 'init' || archivePn == 1) { + archiveList.value = res['data'].list.vlist; + } else { + archiveList.addAll(res['data'].list.vlist); + } + archiveCount = res['data'].page['count']; + if (archiveList.length == archiveCount) { + loadingText.value = '没有更多了'; + } + archivePn += 1; + hasRequest = true; + } + // loadingStatus.value = 'finish'; + return res; + } + + // 搜索动态 + Future searchDynamic() async {} + + // + onLoad() { + searchArchives(type: 'onLoad'); + } +} diff --git a/lib/pages/member_search/index.dart b/lib/pages/member_search/index.dart new file mode 100644 index 00000000..4f10617b --- /dev/null +++ b/lib/pages/member_search/index.dart @@ -0,0 +1,4 @@ +library member_search; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/member_search/view.dart b/lib/pages/member_search/view.dart new file mode 100644 index 00000000..ff0553a2 --- /dev/null +++ b/lib/pages/member_search/view.dart @@ -0,0 +1,195 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/skeleton/video_card_h.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; +import 'package:pilipala/common/widgets/video_card_h.dart'; + +import 'controller.dart'; + +class MemberSearchPage extends StatefulWidget { + const MemberSearchPage({super.key}); + + @override + State createState() => _MemberSearchPageState(); +} + +class _MemberSearchPageState extends State + with SingleTickerProviderStateMixin { + final MemberSearchController _memberSearchCtr = + Get.put(MemberSearchController()); + late ScrollController scrollController; + + @override + void initState() { + super.initState(); + scrollController = _memberSearchCtr.scrollController; + scrollController.addListener( + () { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + EasyThrottle.throttle('history', const Duration(seconds: 1), () { + _memberSearchCtr.onLoad(); + }); + } + }, + ); + // _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + // _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + actions: [ + IconButton( + onPressed: () => _memberSearchCtr.submit(), + icon: const Icon(CupertinoIcons.search, size: 22)), + const SizedBox(width: 10) + ], + title: Obx( + () => TextField( + autofocus: true, + focusNode: _memberSearchCtr.searchFocusNode, + controller: _memberSearchCtr.controller.value, + textInputAction: TextInputAction.search, + onChanged: (value) => _memberSearchCtr.onChange(value), + decoration: InputDecoration( + hintText: _memberSearchCtr.hintText, + border: InputBorder.none, + suffixIcon: IconButton( + icon: Icon( + Icons.clear, + size: 22, + color: Theme.of(context).colorScheme.outline, + ), + onPressed: () => _memberSearchCtr.onClear(), + ), + ), + onSubmitted: (String value) => _memberSearchCtr.submit(), + ), + ), + ), + body: Obx( + () => Column( + children: _memberSearchCtr.loadingStatus.value == 'init' + ? [ + Expanded( + child: Center( + child: Text('搜索「${_memberSearchCtr.uname.value}」的动态、视频'), + ), + ), + ] + : [ + // TabBar( + // controller: _tabController, + // tabs: const [ + // Tab(text: "视频"), + // Tab(text: "动态"), + // ], + // ), + Expanded( + child: + // TabBarView( + // controller: _tabController, + // children: [ + FutureBuilder( + future: _memberSearchCtr.searchArchives(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + return Obx( + () => _memberSearchCtr.archiveList.isNotEmpty + ? ListView.builder( + controller: scrollController, + itemCount: + _memberSearchCtr.archiveList.length + + 1, + itemBuilder: (context, index) { + if (index == + _memberSearchCtr + .archiveList.length) { + return Container( + height: MediaQuery.of(context) + .padding + .bottom + + 60, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .padding + .bottom), + child: Center( + child: Obx( + () => Text( + _memberSearchCtr + .loadingText.value, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline, + fontSize: 13), + ), + ), + ), + ); + } else { + return VideoCardH( + videoItem: _memberSearchCtr + .archiveList[index]); + } + }, + ) + : _memberSearchCtr.loadingStatus.value == + 'loading' + ? ListView.builder( + itemCount: 10, + itemBuilder: (context, index) { + return const VideoCardHSkeleton(); + }, + ) + : const CustomScrollView( + slivers: [ + NoData(), + ], + ), + ); + } else { + return CustomScrollView( + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ) + ], + ); + } + } else { + // 骨架屏 + return ListView.builder( + itemCount: 10, + itemBuilder: (context, index) { + return const VideoCardHSkeleton(); + }, + ); + } + }, + ), + // ], + // ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/controller.dart b/lib/pages/mine/controller.dart index 66813091..2b53850b 100644 --- a/lib/pages/mine/controller.dart +++ b/lib/pages/mine/controller.dart @@ -1,4 +1,5 @@ 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/http/user.dart'; @@ -40,6 +41,7 @@ class MineController extends GetxController { 'pageTitle': '登录bilibili', }, ); + // Get.toNamed('/loginPage'); } else { int mid = userInfo.value.mid!; String face = userInfo.value.face!; @@ -111,4 +113,20 @@ class MineController extends GetxController { } Get.forceAppUpdate(); } + + pushFollow() { + if (!userLogin.value) { + SmartDialog.showToast('账号未登录'); + return; + } + Get.toNamed('/follow?mid=${userInfo.value.mid}'); + } + + pushFans() { + if (!userLogin.value) { + SmartDialog.showToast('账号未登录'); + return; + } + Get.toNamed('/fan?mid=${userInfo.value.mid}'); + } } diff --git a/lib/pages/mine/view.dart b/lib/pages/mine/view.dart index 3698984c..274572af 100644 --- a/lib/pages/mine/view.dart +++ b/lib/pages/mine/view.dart @@ -85,6 +85,9 @@ class _MinePageState extends State { future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SizedBox(); + } if (snapshot.data['status']) { return Obx( () => userInfoBuild(mineController, context)); @@ -261,7 +264,7 @@ class _MinePageState extends State { ), ), InkWell( - onTap: () => Get.toNamed('/follow'), + onTap: () => _mineController.pushFollow(), borderRadius: StyleString.mdRadius, child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -291,7 +294,7 @@ class _MinePageState extends State { ), ), InkWell( - onTap: () => Get.toNamed('/fan'), + onTap: () => _mineController.pushFans(), borderRadius: StyleString.mdRadius, child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/pages/preview/controller.dart b/lib/pages/preview/controller.dart index 500f0b1d..bb06b275 100644 --- a/lib/pages/preview/controller.dart +++ b/lib/pages/preview/controller.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; @@ -17,17 +18,6 @@ class PreviewController extends GetxController { bool photos = true; String currentImgUrl = ''; - @override - void onInit() { - super.onInit(); - if (Get.arguments != null) { - initialPage.value = Get.arguments['initialPage']!; - currentPage.value = Get.arguments['initialPage']! + 1; - imgList.value = Get.arguments['imgList']; - currentImgUrl = imgList[initialPage.value]; - } - } - requestPermission() async { Map statuses = await [ Permission.storage, @@ -40,10 +30,11 @@ class PreviewController extends GetxController { // 图片分享 void onShareImg() async { - requestPermission(); + SmartDialog.showLoading(); var response = await Dio().get(imgList[initialPage.value], options: Options(responseType: ResponseType.bytes)); final temp = await getTemporaryDirectory(); + SmartDialog.dismiss(); String imgName = "plpl_pic_${DateTime.now().toString().split('-').join()}.jpg"; var path = '${temp.path}/$imgName'; diff --git a/lib/pages/preview/view.dart b/lib/pages/preview/view.dart index 65014aac..22cc8952 100644 --- a/lib/pages/preview/view.dart +++ b/lib/pages/preview/view.dart @@ -15,7 +15,13 @@ import 'package:status_bar_control/status_bar_control.dart'; typedef DoubleClickAnimationListener = void Function(); class ImagePreview extends StatefulWidget { - const ImagePreview({Key? key}) : super(key: key); + final int? initialPage; + final List? imgList; + const ImagePreview({ + Key? key, + this.initialPage, + this.imgList, + }) : super(key: key); @override _ImagePreviewState createState() => _ImagePreviewState(); @@ -34,6 +40,11 @@ class _ImagePreviewState extends State @override void initState() { super.initState(); + + _previewController.initialPage.value = widget.initialPage!; + _previewController.currentPage.value = widget.initialPage! + 1; + _previewController.imgList.value = widget.imgList!; + _previewController.currentImgUrl = widget.imgList![widget.initialPage!]; // animationController = AnimationController( // vsync: this, duration: const Duration(milliseconds: 400)); setStatusBar(); @@ -42,9 +53,8 @@ class _ImagePreviewState extends State } onOpenMenu() { - SmartDialog.show( - useSystem: true, - animationType: SmartAnimationType.centerFade_otherSlide, + showDialog( + context: context, builder: (BuildContext context) { return AlertDialog( clipBehavior: Clip.hardEdge, @@ -55,7 +65,7 @@ class _ImagePreviewState extends State ListTile( onTap: () { _previewController.onShareImg(); - SmartDialog.dismiss(); + Get.back(); }, dense: true, title: const Text('分享', style: TextStyle(fontSize: 14)), @@ -65,8 +75,8 @@ class _ImagePreviewState extends State Clipboard.setData( ClipboardData(text: _previewController.currentImgUrl)) .then((value) { + Get.back(); SmartDialog.showToast('已复制到粘贴板'); - SmartDialog.dismiss(); }).catchError((err) { SmartDialog.showNotify( msg: err.toString(), @@ -79,6 +89,7 @@ class _ImagePreviewState extends State ), ListTile( onTap: () { + Get.back(); DownloadUtils.downloadImg(_previewController.currentImgUrl); }, dense: true, @@ -93,13 +104,21 @@ class _ImagePreviewState extends State // 设置状态栏图标透明 setStatusBar() async { - await StatusBarControl.setHidden(true, animation: StatusBarAnimation.SLIDE); + if (Platform.isIOS) { + await StatusBarControl.setHidden(true, + animation: StatusBarAnimation.SLIDE); + } + if (Platform.isAndroid) { + await StatusBarControl.setColor(Colors.transparent); + } } @override void dispose() { // animationController.dispose(); - StatusBarControl.setHidden(false, animation: StatusBarAnimation.SLIDE); + try { + StatusBarControl.setHidden(false, animation: StatusBarAnimation.SLIDE); + } catch (_) {} _doubleClickAnimationController.dispose(); clearGestureDetailsCache(); super.dispose(); @@ -129,109 +148,105 @@ class _ImagePreviewState extends State direction: DismissiblePageDismissDirection.down, disabled: _dismissDisabled, isFullScreen: true, - child: Hero( - tag: _previewController - .imgList[_previewController.initialPage.value], - child: GestureDetector( - onLongPress: () => onOpenMenu(), - child: ExtendedImageGesturePageView.builder( - controller: ExtendedPageController( - initialPage: _previewController.initialPage.value, - pageSpacing: 0, - ), - onPageChanged: (int index) => - _previewController.onChange(index), - canScrollPage: (GestureDetails? gestureDetails) => - gestureDetails!.totalScale! <= 1.0, - preloadPagesCount: 2, - itemCount: _previewController.imgList.length, - itemBuilder: (BuildContext context, int index) { - return ExtendedImage.network( - _previewController.imgList[index], - fit: BoxFit.contain, - mode: ExtendedImageMode.gesture, - onDoubleTap: (ExtendedImageGestureState state) { - final Offset? pointerDownPosition = - state.pointerDownPosition; - final double? begin = state.gestureDetails!.totalScale; - double end; - - //remove old - _doubleClickAnimation - ?.removeListener(_doubleClickAnimationListener); - - //stop pre - _doubleClickAnimationController.stop(); - - //reset to use - _doubleClickAnimationController.reset(); - - if (begin == doubleTapScales[0]) { - setState(() { - _dismissDisabled = true; - }); - end = doubleTapScales[1]; - } else { - setState(() { - _dismissDisabled = false; - }); - end = doubleTapScales[0]; - } - - _doubleClickAnimationListener = () { - state.handleDoubleTap( - scale: _doubleClickAnimation!.value, - doubleTapPosition: pointerDownPosition); - }; - _doubleClickAnimation = _doubleClickAnimationController - .drive(Tween(begin: begin, end: end)); - - _doubleClickAnimation! - .addListener(_doubleClickAnimationListener); - - _doubleClickAnimationController.forward(); - }, - // ignore: body_might_complete_normally_nullable - loadStateChanged: (ExtendedImageState state) { - if (state.extendedImageLoadState == LoadState.loading) { - final ImageChunkEvent? loadingProgress = - state.loadingProgress; - final double? progress = - loadingProgress?.expectedTotalBytes != null - ? loadingProgress!.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 150.0, - child: LinearProgressIndicator( - value: progress, - color: Colors.white, - ), - ), - const SizedBox(height: 10.0), - Text('${((progress ?? 0.0) * 100).toInt()}%'), - ], - ), - ); - } - }, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - inPageView: true, - initialScale: 1.0, - maxScale: 5.0, - animationMaxScale: 6.0, - initialAlignment: InitialAlignment.center, - ); - }, - ); - }, + child: GestureDetector( + onLongPress: () => onOpenMenu(), + child: ExtendedImageGesturePageView.builder( + controller: ExtendedPageController( + initialPage: _previewController.initialPage.value, + pageSpacing: 0, ), + onPageChanged: (int index) => + _previewController.onChange(index), + canScrollPage: (GestureDetails? gestureDetails) => + gestureDetails!.totalScale! <= 1.0, + preloadPagesCount: 2, + itemCount: widget.imgList!.length, + itemBuilder: (BuildContext context, int index) { + return ExtendedImage.network( + widget.imgList![index], + fit: BoxFit.contain, + mode: ExtendedImageMode.gesture, + onDoubleTap: (ExtendedImageGestureState state) { + final Offset? pointerDownPosition = + state.pointerDownPosition; + final double? begin = state.gestureDetails!.totalScale; + double end; + + //remove old + _doubleClickAnimation + ?.removeListener(_doubleClickAnimationListener); + + //stop pre + _doubleClickAnimationController.stop(); + + //reset to use + _doubleClickAnimationController.reset(); + + if (begin == doubleTapScales[0]) { + setState(() { + _dismissDisabled = true; + }); + end = doubleTapScales[1]; + } else { + setState(() { + _dismissDisabled = false; + }); + end = doubleTapScales[0]; + } + + _doubleClickAnimationListener = () { + state.handleDoubleTap( + scale: _doubleClickAnimation!.value, + doubleTapPosition: pointerDownPosition); + }; + _doubleClickAnimation = _doubleClickAnimationController + .drive(Tween(begin: begin, end: end)); + + _doubleClickAnimation! + .addListener(_doubleClickAnimationListener); + + _doubleClickAnimationController.forward(); + }, + // ignore: body_might_complete_normally_nullable + loadStateChanged: (ExtendedImageState state) { + if (state.extendedImageLoadState == LoadState.loading) { + final ImageChunkEvent? loadingProgress = + state.loadingProgress; + final double? progress = + loadingProgress?.expectedTotalBytes != null + ? loadingProgress!.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 150.0, + child: LinearProgressIndicator( + value: progress, + color: Colors.white, + ), + ), + // const SizedBox(height: 10.0), + // Text('${((progress ?? 0.0) * 100).toInt()}%',), + ], + ), + ); + } + }, + initGestureConfigHandler: (ExtendedImageState state) { + return GestureConfig( + inPageView: true, + initialScale: 1.0, + maxScale: 5.0, + animationMaxScale: 6.0, + initialAlignment: InitialAlignment.center, + ); + }, + ); + }, ), ), ), @@ -241,7 +256,7 @@ class _ImagePreviewState extends State bottom: 0, child: Container( padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, top: 20), + bottom: MediaQuery.of(context).padding.bottom + 30), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -262,8 +277,7 @@ class _ImagePreviewState extends State TextSpan( text: _previewController.currentPage.toString()), const TextSpan(text: ' / '), - TextSpan( - text: _previewController.imgList.length.toString()), + TextSpan(text: widget.imgList!.length.toString()), ]), ), ), diff --git a/lib/pages/rcmd/controller.dart b/lib/pages/rcmd/controller.dart index 183b79bf..52c112af 100644 --- a/lib/pages/rcmd/controller.dart +++ b/lib/pages/rcmd/controller.dart @@ -14,12 +14,13 @@ class RcmdController extends GetxController { Box recVideo = GStrorage.recVideo; Box setting = GStrorage.setting; RxInt crossAxisCount = 2.obs; + late bool enableSaveLastData; @override void onInit() { super.onInit(); crossAxisCount.value = - setting.get(SettingBoxKey.enableSingleRow, defaultValue: false) ? 1 : 2; + setting.get(SettingBoxKey.customRows, defaultValue: 2); if (recVideo.get('cacheList') != null && recVideo.get('cacheList').isNotEmpty) { List list = []; @@ -28,6 +29,8 @@ class RcmdController extends GetxController { } videoList.value = list; } + enableSaveLastData = + setting.get(SettingBoxKey.enableSaveLastData, defaultValue: false); } // 获取推荐 @@ -49,7 +52,11 @@ class RcmdController extends GetxController { videoList.value = res['data']; } } else if (type == 'onRefresh') { - videoList.insertAll(0, res['data']); + if (enableSaveLastData) { + videoList.insertAll(0, res['data']); + } else { + videoList.value = res['data']; + } } else if (type == 'onLoad') { videoList.addAll(res['data']); } diff --git a/lib/pages/rcmd/view.dart b/lib/pages/rcmd/view.dart index c8cf20d4..51771d3c 100644 --- a/lib/pages/rcmd/view.dart +++ b/lib/pages/rcmd/view.dart @@ -77,7 +77,8 @@ class _RcmdPageState extends State ), child: RefreshIndicator( onRefresh: () async { - return await _rcmdController.onRefresh(); + await _rcmdController.onRefresh(); + await Future.delayed(const Duration(milliseconds: 300)); }, child: CustomScrollView( controller: _rcmdController.scrollController, @@ -124,7 +125,7 @@ class _RcmdPageState extends State }, ), ), - const LoadingMore() + LoadingMore(ctr: _rcmdController) ], ), ), @@ -190,7 +191,8 @@ class _RcmdPageState extends State } class LoadingMore extends StatelessWidget { - const LoadingMore({super.key}); + dynamic ctr; + LoadingMore({super.key, this.ctr}); @override Widget build(BuildContext context) { @@ -198,11 +200,18 @@ class LoadingMore extends StatelessWidget { child: Container( height: MediaQuery.of(context).padding.bottom + 80, padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), - child: Center( - child: Text( - '加载中...', - style: TextStyle( - color: Theme.of(context).colorScheme.outline, fontSize: 13), + child: GestureDetector( + onTap: () { + if (ctr != null) { + ctr!.onLoad(); + } + }, + child: Center( + child: Text( + '加载更多 👇', + style: TextStyle( + color: Theme.of(context).colorScheme.outline, fontSize: 13), + ), ), ), ), diff --git a/lib/pages/search/controller.dart b/lib/pages/search/controller.dart index 64f3cb65..59d51a41 100644 --- a/lib/pages/search/controller.dart +++ b/lib/pages/search/controller.dart @@ -12,7 +12,7 @@ class SSearchController extends GetxController { final FocusNode searchFocusNode = FocusNode(); RxString searchKeyWord = ''.obs; Rx controller = TextEditingController().obs; - RxList hotSearchList = [HotSearchItem()].obs; + RxList hotSearchList = [].obs; Box histiryWord = GStrorage.historyword; List historyCacheList = []; RxList historyList = [].obs; @@ -27,7 +27,9 @@ class SSearchController extends GetxController { @override void onInit() { super.onInit(); - searchDefault(); + if (setting.get(SettingBoxKey.enableSearchWord, defaultValue: true)) { + searchDefault(); + } // 其他页面跳转过来 if (Get.parameters.keys.isNotEmpty) { if (Get.parameters['keyword'] != null) { @@ -83,7 +85,9 @@ class SSearchController extends GetxController { // 获取热搜关键词 Future queryHotSearchList() async { var result = await SearchHttp.hotSearchList(); - hotSearchList.value = result['data'].list; + if (result['status']) { + hotSearchList.value = result['data'].list; + } return result; } @@ -101,7 +105,9 @@ class SSearchController extends GetxController { Future querySearchSuggest(String value) async { var result = await SearchHttp.searchSuggest(term: value); if (result['status']) { - searchSuggestList.value = result['data'].tag; + if (result['data'] is SearchSuggestModel) { + searchSuggestList.value = result['data'].tag; + } } } @@ -111,6 +117,13 @@ class SSearchController extends GetxController { submit(); } + onLongSelect(word) { + int index = historyList.indexOf(word); + historyList.value = historyList.removeAt(index); + historyList.refresh(); + histiryWord.put('cacheList', historyList); + } + onClearHis() { historyList.value = []; historyCacheList = []; diff --git a/lib/pages/search/view.dart b/lib/pages/search/view.dart index c78dfea9..0ec910f1 100644 --- a/lib/pages/search/view.dart +++ b/lib/pages/search/view.dart @@ -227,6 +227,9 @@ class _SearchPageState extends State with RouteAware { future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SizedBox(); + } Map data = snapshot.data as Map; if (data['status']) { return Obx( @@ -296,20 +299,24 @@ class _SearchPageState extends State with RouteAware { ), ), // if (_searchController.historyList.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - direction: Axis.horizontal, - textDirection: TextDirection.ltr, - children: [ - for (int i = 0; i < _searchController.historyList.length; i++) - SearchText( - searchText: _searchController.historyList[i], - searchTextIdx: i, - onSelect: (value) => _searchController.onSelect(value), - ) - ], - ), + Obx(() => Wrap( + spacing: 8, + runSpacing: 8, + direction: Axis.horizontal, + textDirection: TextDirection.ltr, + children: [ + for (int i = 0; + i < _searchController.historyList.length; + i++) + SearchText( + searchText: _searchController.historyList[i], + searchTextIdx: i, + onSelect: (value) => _searchController.onSelect(value), + onLongSelect: (value) => + _searchController.onLongSelect(value), + ) + ], + )), ], ), ), diff --git a/lib/pages/search/widgets/search_text.dart b/lib/pages/search/widgets/search_text.dart index 9f5f84c3..039a851b 100644 --- a/lib/pages/search/widgets/search_text.dart +++ b/lib/pages/search/widgets/search_text.dart @@ -4,8 +4,14 @@ class SearchText extends StatelessWidget { final String? searchText; final Function? onSelect; final int? searchTextIdx; - const SearchText( - {super.key, this.searchText, this.onSelect, this.searchTextIdx}); + final Function? onLongSelect; + const SearchText({ + super.key, + this.searchText, + this.onSelect, + this.searchTextIdx, + this.onLongSelect, + }); @override Widget build(BuildContext context) { @@ -18,6 +24,9 @@ class SearchText extends StatelessWidget { onTap: () { onSelect!(searchText); }, + onLongPress: () { + onLongSelect!(searchText); + }, borderRadius: BorderRadius.circular(6), child: Padding( padding: diff --git a/lib/pages/searchPanel/view.dart b/lib/pages/searchPanel/view.dart index 346e5048..9fd37b7e 100644 --- a/lib/pages/searchPanel/view.dart +++ b/lib/pages/searchPanel/view.dart @@ -7,6 +7,7 @@ import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/models/common/search_type.dart'; import 'controller.dart'; +import 'widgets/article_panel.dart'; import 'widgets/live_panel.dart'; import 'widgets/media_bangumi_panel.dart'; import 'widgets/user_panel.dart'; @@ -90,6 +91,8 @@ class _SearchPanelState extends State return searchUserPanel(context, ctr, list); case SearchType.live_room: return searchLivePanel(context, ctr, list); + case SearchType.article: + return searchArticlePanel(context, ctr, list); default: return const SizedBox(); } diff --git a/lib/pages/searchPanel/widgets/article_panel.dart b/lib/pages/searchPanel/widgets/article_panel.dart new file mode 100644 index 00000000..6e73151a --- /dev/null +++ b/lib/pages/searchPanel/widgets/article_panel.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/utils/utils.dart'; + +Widget searchArticlePanel(BuildContext context, ctr, list) { + TextStyle textStyle = TextStyle( + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + color: Theme.of(context).colorScheme.outline); + return ListView.builder( + controller: ctr!.scrollController, + itemCount: list.length, + itemBuilder: (context, index) { + return InkWell( + onTap: () { + Get.toNamed('/htmlRender', parameters: { + 'url': 'www.bilibili.com/read/cv${list[index].id}', + 'title': list[index].subTitle, + 'id': 'cv${list[index].id}', + 'dynamicType': 'read' + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB( + StyleString.safeSpace, 5, StyleString.safeSpace, 5), + child: LayoutBuilder(builder: (context, boxConstraints) { + double width = (boxConstraints.maxWidth - + StyleString.cardSpace * + 6 / + MediaQuery.of(context).textScaleFactor) / + 2; + return Container( + constraints: const BoxConstraints(minHeight: 88), + height: width / StyleString.aspectRatio, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (list[index].imageUrls != null && + list[index].imageUrls.isNotEmpty) + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder(builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return NetworkImgLayer( + width: maxWidth, + height: maxHeight, + src: list[index].imageUrls.first, + ); + }), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 2, + text: TextSpan( + children: [ + for (var i in list[index].title) ...[ + TextSpan( + text: i['text'], + style: TextStyle( + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + color: i['type'] == 'em' + ? Theme.of(context) + .colorScheme + .primary + : Theme.of(context) + .colorScheme + .onSurface, + ), + ), + ] + ], + ), + ), + const Spacer(), + Text( + Utils.dateFormat(list[index].pubTime, + formatType: 'detail'), + style: textStyle), + Row( + children: [ + Text('${list[index].view}浏览', style: textStyle), + Text(' • ', style: textStyle), + Text('${list[index].reply}评论', style: textStyle), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }), + ), + ); + }, + ); +} diff --git a/lib/pages/setting/extra_setting.dart b/lib/pages/setting/extra_setting.dart index f9d30f41..6f1ff07e 100644 --- a/lib/pages/setting/extra_setting.dart +++ b/lib/pages/setting/extra_setting.dart @@ -16,8 +16,12 @@ class ExtraSetting extends StatefulWidget { class _ExtraSettingState extends State { Box setting = GStrorage.setting; + static Box localCache = GStrorage.localCache; late dynamic defaultReplySort; late dynamic defaultDynamicType; + late dynamic enableSystemProxy; + late String defaultSystemProxyHost; + late String defaultSystemProxyPort; @override void initState() { @@ -28,6 +32,86 @@ class _ExtraSettingState extends State { // 优先展示全部动态 all defaultDynamicType = setting.get(SettingBoxKey.defaultDynamicType, defaultValue: 0); + enableSystemProxy = + setting.get(SettingBoxKey.enableSystemProxy, defaultValue: false); + defaultSystemProxyHost = + localCache.get(LocalCacheKey.systemProxyHost, defaultValue: ''); + defaultSystemProxyPort = + localCache.get(LocalCacheKey.systemProxyPort, defaultValue: ''); + } + + // 设置代理 + void twoFADialog() { + var systemProxyHost = ''; + var systemProxyPort = ''; + + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('设置代理'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 6), + TextField( + decoration: InputDecoration( + isDense: true, + labelText: defaultSystemProxyHost != '' + ? defaultSystemProxyHost + : '请输入Host,使用 . 分割', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6.0), + ), + hintText: defaultSystemProxyHost, + ), + onChanged: (e) { + systemProxyHost = e; + }, + ), + const SizedBox(height: 10), + TextField( + keyboardType: TextInputType.number, + decoration: InputDecoration( + isDense: true, + labelText: defaultSystemProxyPort != '' + ? defaultSystemProxyPort + : '请输入Port', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6.0), + ), + hintText: defaultSystemProxyPort, + ), + onChanged: (e) { + systemProxyPort = e; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () async { + SmartDialog.dismiss(); + }, + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () async { + localCache.put(LocalCacheKey.systemProxyHost, systemProxyHost); + localCache.put(LocalCacheKey.systemProxyPort, systemProxyPort); + SmartDialog.dismiss(); + // Request.dio; + }, + child: const Text('确认'), + ) + ], + ); + }, + ); } @override @@ -55,6 +139,18 @@ class _ExtraSettingState extends State { defaultVal: true, callFn: (val) => {SmartDialog.showToast('下次启动时生效')}, ), + const SetSwitchItem( + title: '搜索默认词', + subTitle: '是否展示搜索框默认词', + setKey: SettingBoxKey.enableSearchWord, + defaultVal: true, + ), + const SetSwitchItem( + title: '推荐动态', + subTitle: '是否在推荐内容中展示动态', + setKey: SettingBoxKey.enableRcmdDynamic, + defaultVal: true, + ), const SetSwitchItem( title: '快速收藏', subTitle: '点按收藏至默认,长按选择文件夹', @@ -67,6 +163,12 @@ class _ExtraSettingState extends State { setKey: SettingBoxKey.enableWordRe, defaultVal: false, ), + const SetSwitchItem( + title: '首页推荐刷新', + subTitle: '下拉刷新时保留上次内容', + setKey: SettingBoxKey.enableSaveLastData, + defaultVal: false, + ), ListTile( dense: false, title: Text('评论展示', style: titleStyle), @@ -117,6 +219,33 @@ class _ExtraSettingState extends State { ], ), ), + ListTile( + enableFeedback: true, + onTap: () => twoFADialog(), + title: Text('设置代理', style: titleStyle), + subtitle: Text('设置代理 host:port', style: subTitleStyle), + trailing: Transform.scale( + scale: 0.8, + child: Switch( + thumbIcon: MaterialStateProperty.resolveWith( + (Set states) { + if (states.isNotEmpty && + states.first == MaterialState.selected) { + return const Icon(Icons.done); + } + return null; // All other states will use the default thumbIcon. + }), + value: enableSystemProxy, + onChanged: (val) { + setting.put( + SettingBoxKey.enableSystemProxy, !enableSystemProxy); + setState(() { + enableSystemProxy = !enableSystemProxy; + }); + }, + ), + ), + ), const SetSwitchItem( title: '检查更新', subTitle: '每次启动时检查是否需要更新', diff --git a/lib/pages/setting/pages/display_mode.dart b/lib/pages/setting/pages/display_mode.dart index 4e84e956..0dcf1958 100644 --- a/lib/pages/setting/pages/display_mode.dart +++ b/lib/pages/setting/pages/display_mode.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/utils/storage.dart'; class SetDiaplayMode extends StatefulWidget { const SetDiaplayMode({super.key}); @@ -14,6 +16,7 @@ class _SetDiaplayModeState extends State { List modes = []; DisplayMode? active; DisplayMode? preferred; + Box setting = GStrorage.setting; final ValueNotifier page = ValueNotifier(0); late final PageController controller = PageController() @@ -29,24 +32,36 @@ class _SetDiaplayModeState extends State { }); } + // 获取所有的mode Future fetchAll() async { preferred = await FlutterDisplayMode.preferred; active = await FlutterDisplayMode.active; - // GStorage().setDisplayModeType(preferred!); + await setting.put(SettingBoxKey.displayMode, preferred.toString()); setState(() {}); } + // 初始化mode/手动设置 Future init() async { try { modes = await FlutterDisplayMode.supported; } on PlatformException catch (e) { print(e); } - // var res = await GStorage().getDisplayModeType(); - // preferred = modes.toList().firstWhere((el) => el == res); + var res = await getDisplayModeType(modes); + + preferred = modes.toList().firstWhere((el) => el == res); FlutterDisplayMode.setPreferredMode(preferred!); } + Future getDisplayModeType(modes) async { + var value = setting.get(SettingBoxKey.displayMode); + DisplayMode f = DisplayMode.auto; + if (value != null) { + f = modes.firstWhere((e) => e.toString() == value); + } + return f; + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/pages/setting/pages/play_speed_set.dart b/lib/pages/setting/pages/play_speed_set.dart new file mode 100644 index 00000000..c5eb70e6 --- /dev/null +++ b/lib/pages/setting/pages/play_speed_set.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/plugin/pl_player/models/play_speed.dart'; +import 'package:pilipala/utils/storage.dart'; + +class PlaySpeedPage extends StatefulWidget { + const PlaySpeedPage({super.key}); + + @override + State createState() => _PlaySpeedPageState(); +} + +class _PlaySpeedPageState extends State { + Box videoStorage = GStrorage.video; + late double playSpeedDefault; + late double longPressSpeedDefault; + late List customSpeedsList; + List> sheetMenu = [ + { + 'id': 1, + 'title': '设置为默认倍速', + 'leading': const Icon( + Icons.speed, + size: 21, + ), + }, + { + 'id': 2, + 'title': '设置为默认长按倍速', + 'leading': const Icon( + Icons.speed_sharp, + size: 21, + ), + }, + { + 'id': -1, + 'title': '删除该项', + 'leading': const Icon( + Icons.delete_outline, + size: 21, + ), + }, + ]; + + @override + void initState() { + super.initState(); + // 默认倍速 + playSpeedDefault = + videoStorage.get(VideoBoxKey.playSpeedDefault, defaultValue: 1.0); + // 默认长按倍速 + longPressSpeedDefault = + videoStorage.get(VideoBoxKey.longPressSpeedDefault, defaultValue: 2.0); + // 自定义倍速 + customSpeedsList = + videoStorage.get(VideoBoxKey.customSpeedsList, defaultValue: []); + } + + // 添加自定义倍速 + void onAddSpeed() { + double customSpeed = 1.0; + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('添加倍速'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // const Text('输入你想要的视频倍速,例如:1.0'), + const SizedBox(height: 12), + TextField( + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: '自定义倍速', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6.0), + ), + ), + onChanged: (e) { + customSpeed = double.parse(e); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + customSpeedsList.add(customSpeed); + await videoStorage.put( + VideoBoxKey.customSpeedsList, customSpeedsList); + setState(() {}); + SmartDialog.dismiss(); + }, + child: const Text('确认添加'), + ) + ], + ); + }, + ); + } + + // 设定倍速弹窗 + void showBottomSheet(type, i) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return Container( + padding: const EdgeInsets.only(top: 10), + child: ListView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + //重要 + itemCount: sheetMenu.length, + itemBuilder: (BuildContext context, int index) { + return ListTile( + onTap: () { + Navigator.pop(context); + menuAction(type, i, sheetMenu[index]['id']); + }, + minLeadingWidth: 0, + iconColor: Theme.of(context).colorScheme.onSurface, + leading: sheetMenu[index]['leading'], + title: Text( + sheetMenu[index]['title'], + style: Theme.of(context).textTheme.titleSmall, + ), + ); + }, + ), + ); + }, + ); + } + + // + void menuAction(type, index, id) async { + double chooseSpeed = 1.0; + if (type == 'system' && id == -1) { + SmartDialog.showToast('系统预设倍速不支持删除'); + return; + } + // 获取当前选中的倍速值 + if (type == 'system') { + chooseSpeed = PlaySpeed.values[index].value; + } else { + chooseSpeed = customSpeedsList[index]; + } + // 设置 + if (id == 1) { + // 设置默认倍速 + playSpeedDefault = chooseSpeed; + videoStorage.put(VideoBoxKey.playSpeedDefault, playSpeedDefault); + } else if (id == 2) { + // 设置默认长按倍速 + longPressSpeedDefault = chooseSpeed; + videoStorage.put( + VideoBoxKey.longPressSpeedDefault, longPressSpeedDefault); + } else if (id == -1) { + if (customSpeedsList[index] == playSpeedDefault) { + playSpeedDefault = 1.0; + videoStorage.put(VideoBoxKey.playSpeedDefault, playSpeedDefault); + } + if (customSpeedsList[index] == longPressSpeedDefault) { + longPressSpeedDefault = 2.0; + videoStorage.put( + VideoBoxKey.longPressSpeedDefault, longPressSpeedDefault); + } + customSpeedsList.removeAt(index); + await videoStorage.put(VideoBoxKey.customSpeedsList, customSpeedsList); + } + setState(() {}); + SmartDialog.showToast('操作成功'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + scrolledUnderElevation: 0, + titleSpacing: 0, + centerTitle: false, + title: Text( + '倍速设置', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.only(left: 14, right: 14, top: 6, bottom: 0), + child: Text( + '点击下方按钮设置默认(长按)倍速', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + ListTile( + dense: false, + title: const Text('默认倍速'), + subtitle: Text(playSpeedDefault.toString()), + ), + ListTile( + dense: false, + title: const Text('默认长按倍速'), + subtitle: Text(longPressSpeedDefault.toString()), + ), + Padding( + padding: const EdgeInsets.only( + left: 14, + right: 14, + bottom: 10, + top: 20, + ), + child: Text( + '系统预设倍速', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 18, + right: 18, + bottom: 30, + ), + child: Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 2, + children: [ + for (var i in PlaySpeed.values) ...[ + FilledButton.tonal( + onPressed: () => showBottomSheet('system', i.index), + child: Text(i.description), + ), + ] + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 14, + right: 14, + ), + child: Row( + children: [ + Text( + '自定义倍速', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 12), + TextButton( + onPressed: () => onAddSpeed(), + child: const Text('添加'), + ) + ], + )), + Padding( + padding: EdgeInsets.only( + left: 18, + right: 18, + bottom: MediaQuery.of(context).padding.bottom + 40, + ), + child: customSpeedsList.isNotEmpty + ? Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 2, + children: [ + for (int i = 0; i < customSpeedsList.length; i++) ...[ + FilledButton.tonal( + onPressed: () => showBottomSheet('custom', i), + child: Text(customSpeedsList[i].toString()), + ), + ] + ], + ) + : SizedBox( + height: 80, + child: Center( + child: Text( + '未添加', + style: TextStyle( + color: Theme.of(context).colorScheme.outline), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/setting/play_setting.dart b/lib/pages/setting/play_setting.dart index a6197def..6dbfefcf 100644 --- a/lib/pages/setting/play_setting.dart +++ b/lib/pages/setting/play_setting.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/models/video/play/quality.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/services/service_locator.dart'; import 'package:pilipala/utils/storage.dart'; import 'widgets/switch_item.dart'; @@ -36,6 +38,14 @@ class _PlaySettingState extends State { defaultValue: BtmProgresBehavior.values.first.code); } + @override + void dispose() { + super.dispose(); + + // 重新验证媒体通知后台播放设置 + videoPlayerServiceHandler.revalidateSetting(); + } + @override Widget build(BuildContext context) { TextStyle titleStyle = Theme.of(context).textTheme.titleMedium!; @@ -54,12 +64,42 @@ class _PlaySettingState extends State { ), body: ListView( children: [ + ListTile( + dense: false, + onTap: () => Get.toNamed('/playSpeedSet'), + title: Text('倍速设置', style: titleStyle), + trailing: const Icon(Icons.arrow_forward_ios, size: 17), + ), + const SetSwitchItem( + title: '开启1080P', + subTitle: '免登录查看1080P视频', + setKey: SettingBoxKey.p1080, + defaultVal: true, + ), + const SetSwitchItem( + title: 'CDN优化', + subTitle: '使用优质CDN线路', + setKey: SettingBoxKey.enableCDN, + defaultVal: true, + ), const SetSwitchItem( title: '自动播放', subTitle: '进入详情页自动播放', setKey: SettingBoxKey.autoPlayEnable, defaultVal: true, ), + const SetSwitchItem( + title: '后台播放', + subTitle: '进入后台时继续播放', + setKey: SettingBoxKey.enableBackgroundPlay, + defaultVal: false, + ), + const SetSwitchItem( + title: '自动PiP播放', + subTitle: 'app切换至后台时画中画播放', + setKey: SettingBoxKey.autoPiP, + defaultVal: false, + ), const SetSwitchItem( title: '自动全屏', subTitle: '视频开始播放时进入全屏', diff --git a/lib/pages/setting/style_setting.dart b/lib/pages/setting/style_setting.dart index c502beeb..2256b2fd 100644 --- a/lib/pages/setting/style_setting.dart +++ b/lib/pages/setting/style_setting.dart @@ -1,7 +1,6 @@ import 'dart:io'; 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/common/theme_type.dart'; @@ -22,12 +21,14 @@ class _StyleSettingState extends State { Box setting = GStrorage.setting; late int picQuality; late ThemeType _tempThemeValue; + late dynamic defaultCustomRows; @override void initState() { super.initState(); picQuality = setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10); _tempThemeValue = settingController.themeType.value; + defaultCustomRows = setting.get(SettingBoxKey.customRows, defaultValue: 2); } @override @@ -76,12 +77,43 @@ class _StyleSettingState extends State { setKey: SettingBoxKey.iosTransition, defaultVal: false, ), - SetSwitchItem( - title: '首页单列', - subTitle: '每行展示一个内容卡片', - setKey: SettingBoxKey.enableSingleRow, - defaultVal: false, - callFn: (val) => {SmartDialog.showToast('下次启动时生效')}, + const SetSwitchItem( + title: 'MD3样式底栏', + subTitle: '符合Material You设计规范的底栏', + setKey: SettingBoxKey.enableMYBar, + defaultVal: true, + ), + // SetSwitchItem( + // title: '首页单列', + // subTitle: '每行展示一个内容卡片', + // setKey: SettingBoxKey.enableSingleRow, + // defaultVal: false, + // callFn: (val) => {SmartDialog.showToast('下次启动时生效')}, + // ), + ListTile( + dense: false, + title: Text('自定义列数', style: titleStyle), + subtitle: Text( + '当前列数', + style: subTitleStyle, + ), + trailing: PopupMenuButton( + initialValue: defaultCustomRows, + icon: const Icon(Icons.more_vert_outlined, size: 22), + onSelected: (item) { + defaultCustomRows = item; + setting.put(SettingBoxKey.customRows, item); + setState(() {}); + }, + itemBuilder: (BuildContext context) => [ + for (var i in [1, 2, 3, 4, 5]) ...[ + PopupMenuItem( + value: i, + child: Text(i.toString()), + ), + ] + ], + ), ), ListTile( dense: false, diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index ee671f0a..6fa0df6f 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -14,13 +16,16 @@ import 'package:pilipala/pages/video/detail/replyReply/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; +import 'package:pilipala/utils/video_utils.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'widgets/header_control.dart'; + class VideoDetailController extends GetxController with GetSingleTickerProviderStateMixin { /// 路由传参 String bvid = Get.parameters['bvid']!; - int cid = int.parse(Get.parameters['cid']!); + RxInt cid = int.parse(Get.parameters['cid']!).obs; RxInt danmakuCid = 0.obs; String heroTag = Get.arguments['heroTag']; // 视频详情 @@ -76,6 +81,13 @@ class VideoDetailController extends GetxController bool enableHeart = true; var userInfo; late bool isFirstTime = true; + Floating? floating; + late PreferredSizeWidget headerControl; + + late bool enableCDN; + late int? cacheVideoQa; + late String cacheDecode; + late int cacheAudioQa; @override void onInit() { @@ -103,7 +115,26 @@ class VideoDetailController extends GetxController localCache.get(LocalCacheKey.historyPause) == true) { enableHeart = false; } - danmakuCid.value = cid; + danmakuCid.value = cid.value; + + /// + if (Platform.isAndroid) { + floating = Floating(); + } + headerControl = HeaderControl( + controller: plPlayerController, + videoDetailCtr: this, + floating: floating, + ); + // CDN优化 + enableCDN = setting.get(SettingBoxKey.enableCDN, defaultValue: true); + // 预设的画质 + cacheVideoQa = setting.get(SettingBoxKey.defaultVideoQa); + // 预设的解码格式 + cacheDecode = setting.get(SettingBoxKey.defaultDecode, + defaultValue: VideoDecodeFormats.values.last.code); + cacheAudioQa = setting.get(SettingBoxKey.defaultAudioQa, + defaultValue: AudioQuality.hiRes.code); } showReplyReplyPanel() { @@ -167,7 +198,13 @@ class VideoDetailController extends GetxController playerInit(); } - Future playerInit({video, audio, seekToTime, duration}) async { + Future playerInit({ + video, + audio, + seekToTime, + duration, + bool autoplay = true, + }) async { /// 设置/恢复 屏幕亮度 if (brightness != null) { ScreenBrightness().setScreenBrightness(brightness!); @@ -193,36 +230,35 @@ class VideoDetailController extends GetxController direction: (firstVideo.width! - firstVideo.height!) > 0 ? 'horizontal' : 'vertical', - // 默认1倍速 - speed: 1.0, bvid: bvid, - cid: cid, + cid: cid.value, enableHeart: enableHeart, isFirstTime: isFirstTime, + autoplay: autoplay, ); + + /// 开启自动全屏时,在player初始化完成后立即传入headerControl + plPlayerController.headerControl = headerControl; } // 视频链接 Future queryVideoUrl() async { - var result = await VideoHttp.videoUrl(cid: cid, bvid: bvid); + var result = await VideoHttp.videoUrl(cid: cid.value, bvid: bvid); if (result['status']) { data = result['data']; - List allVideosList = data.dash!.video!; - try { // 当前可播放的最高质量视频 int currentHighVideoQa = allVideosList.first.quality!.code; - // 使用预设的画质 | 当前可用的最高质量 - int cacheVideoQa = setting.get(SettingBoxKey.defaultVideoQa, - defaultValue: currentHighVideoQa); + // 预设的画质为null,则当前可用的最高质量 + cacheVideoQa ??= currentHighVideoQa; int resVideoQa = currentHighVideoQa; - if (cacheVideoQa <= currentHighVideoQa) { + if (cacheVideoQa! <= currentHighVideoQa) { // 如果预设的画质低于当前最高 List numbers = data.acceptQuality! .where((e) => e <= currentHighVideoQa) .toList(); - resVideoQa = Utils.findClosestNumber(cacheVideoQa, numbers); + resVideoQa = Utils.findClosestNumber(cacheVideoQa!, numbers); } currentVideoQa = VideoQualityCode.fromCode(resVideoQa)!; @@ -236,9 +272,7 @@ class VideoDetailController extends GetxController List supportDecodeFormats = supportFormats.firstWhere((e) => e.quality == resVideoQa).codecs!; // 默认从设置中取AVC - currentDecodeFormats = VideoDecodeFormatsCode.fromString(setting.get( - SettingBoxKey.defaultDecode, - defaultValue: VideoDecodeFormats.values.last.code))!; + currentDecodeFormats = VideoDecodeFormatsCode.fromString(cacheDecode)!; try { // 当前视频没有对应格式返回第一个 bool flag = false; @@ -250,8 +284,8 @@ class VideoDetailController extends GetxController currentDecodeFormats = flag ? currentDecodeFormats : VideoDecodeFormatsCode.fromString(supportDecodeFormats.first)!; - } catch (e) { - print(e); + } catch (err) { + SmartDialog.showToast('DecodeFormats error: $err'); } /// 取出符合当前解码格式的videoItem @@ -261,9 +295,11 @@ class VideoDetailController extends GetxController } catch (_) { firstVideo = videosList.first; } - videoUrl = firstVideo.baseUrl!; + videoUrl = enableCDN + ? VideoUtils.getCdnUrl(firstVideo) + : (firstVideo.backupUrl ?? firstVideo.baseUrl!); } catch (err) { - print(err); + SmartDialog.showToast('firstVideo error: $err'); } /// 优先顺序 设置中指定质量 -> 当前可选的最高质量 @@ -271,9 +307,6 @@ class VideoDetailController extends GetxController List audiosList = data.dash!.audio!; try { - int resultAudioQa = setting.get(SettingBoxKey.defaultAudioQa, - defaultValue: AudioQuality.hiRes.code); - if (data.dash!.dolby?.audio?.isNotEmpty == true) { // 杜比 audiosList.insert(0, data.dash!.dolby!.audio!.first); @@ -286,14 +319,23 @@ class VideoDetailController extends GetxController if (audiosList.isNotEmpty) { List numbers = audiosList.map((map) => map.id!).toList(); - int closestNumber = Utils.findClosestNumber(resultAudioQa, numbers); + int closestNumber = Utils.findClosestNumber(cacheAudioQa, numbers); + if (!numbers.contains(cacheAudioQa) && + numbers.any((e) => e > cacheAudioQa)) { + closestNumber = 30280; + } firstAudio = audiosList.firstWhere((e) => e.id == closestNumber); + } else { + firstAudio = AudioItem(); } - } catch (e) { - print(e); + } catch (err) { + firstAudio = audiosList.isNotEmpty ? audiosList.first : AudioItem(); + SmartDialog.showToast('firstAudio error: $err'); } - audioUrl = firstAudio!.baseUrl ?? ''; + audioUrl = enableCDN + ? VideoUtils.getCdnUrl(firstAudio) + : (firstAudio.backupUrl ?? firstAudio.baseUrl!); // if (firstAudio.id != null) { currentAudioQa = AudioQualityCode.fromCode(firstAudio.id!)!; diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 2b47a1da..4590ec44 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -8,14 +8,18 @@ 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'; +import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:share_plus/share_plus.dart'; +import 'widgets/group_panel.dart'; + class VideoIntroController extends GetxController { // 视频bvid String bvid = Get.parameters['bvid']!; @@ -58,11 +62,16 @@ class VideoIntroController extends GetxController { RxString total = '1'.obs; Timer? timer; bool isPaused = false; + String heroTag = ''; + late ModelResult modelResult; @override void onInit() { super.onInit(); userInfo = userInfoCache.get('userInfoCache'); + try { + heroTag = Get.arguments['heroTag']; + } catch (_) {} if (Get.arguments.isNotEmpty) { if (Get.arguments.containsKey('videoItem')) { preRender = true; @@ -102,9 +111,10 @@ class VideoIntroController extends GetxController { if (videoDetail.value.pages!.isNotEmpty && lastPlayCid.value == 0) { lastPlayCid.value = videoDetail.value.pages!.first.cid!; } - Get.find(tag: Get.arguments['heroTag']) - .tabs - .value = ['简介', '评论 ${result['data']!.stat!.reply}']; + // Get.find(tag: heroTag).tabs.value = [ + // '简介', + // '评论 ${result['data']!.stat!.reply}' + // ]; // 获取到粉丝数再返回 await queryUserStat(); } @@ -330,7 +340,8 @@ class VideoIntroController extends GetxController { // 分享视频 Future actionShareVideo() async { - var result = await Share.share('${HttpString.baseUrl}/video/$bvid') + var result = await Share.share( + '${videoDetail.value.title} - ${HttpString.baseUrl}/video/$bvid') .whenComplete(() {}); return result; } @@ -424,6 +435,20 @@ class VideoIntroController extends GetxController { } followStatus['attribute'] = actionStatus; followStatus.refresh(); + if (actionStatus == 2) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('关注成功'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + label: '设置分组', + onPressed: setFollowGroup, + ), + ), + ); + } + } } SmartDialog.dismiss(); }, @@ -439,16 +464,16 @@ class VideoIntroController extends GetxController { Future changeSeasonOrbangu(bvid, cid, aid) async { // 重新获取视频资源 VideoDetailController videoDetailCtr = - Get.find(tag: Get.arguments['heroTag']); + Get.find(tag: heroTag); videoDetailCtr.bvid = bvid; - videoDetailCtr.cid = cid; + videoDetailCtr.cid.value = cid; videoDetailCtr.danmakuCid.value = cid; videoDetailCtr.queryVideoUrl(); // 重新请求评论 try { /// 未渲染回复组件时可能异常 VideoReplyController videoReplyCtr = - Get.find(tag: Get.arguments['heroTag']); + Get.find(tag: heroTag); videoReplyCtr.aid = aid; videoReplyCtr.queryReplyList(type: 'init'); } catch (_) {} @@ -485,4 +510,74 @@ class VideoIntroController extends GetxController { } super.onClose(); } + + /// 列表循环或者顺序播放时,自动播放下一个 + void nextPlay() { + late List episodes; + bool isPages = false; + if (videoDetail.value.ugcSeason != null) { + UgcSeason ugcSeason = videoDetail.value.ugcSeason!; + List sections = ugcSeason.sections!; + episodes = []; + + for (int i = 0; i < sections.length; i++) { + List episodesList = sections[i].episodes!; + episodes.addAll(episodesList); + } + } else if (videoDetail.value.pages != null) { + isPages = true; + List pages = videoDetail.value.pages!; + episodes = []; + episodes.addAll(pages); + } + + int currentIndex = episodes.indexWhere((e) => e.cid == lastPlayCid.value); + int nextIndex = currentIndex + 1; + VideoDetailController videoDetailCtr = + Get.find(tag: heroTag); + PlayRepeat platRepeat = videoDetailCtr.plPlayerController.playRepeat; + + // 列表循环 + if (nextIndex >= episodes.length) { + if (platRepeat == PlayRepeat.listCycle) { + nextIndex = 0; + } + if (platRepeat == PlayRepeat.listOrder) { + return; + } + } + int cid = episodes[nextIndex].cid!; + String rBvid = isPages ? bvid : episodes[nextIndex].bvid; + int rAid = isPages ? IdUtils.bv2av(bvid) : episodes[nextIndex].aid!; + changeSeasonOrbangu(rBvid, cid, rAid); + } + + // 设置关注分组 + void setFollowGroup() { + Get.bottomSheet( + GroupPanel(mid: videoDetail.value.owner!.mid!), + 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 410c14c6..eb954510 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -11,6 +11,8 @@ 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/services/service_locator.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; @@ -199,6 +201,9 @@ class _VideoInfoState extends State with TickerProviderStateMixin { // 视频介绍 showIntroDetail() { + if (loadingStatus) { + return; + } feedBack(); showBottomSheet( context: context, @@ -223,6 +228,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,103 +254,100 @@ class _VideoInfoState extends State with TickerProviderStateMixin { GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => showIntroDetail(), - child: Row( - children: [ - Expanded( - child: Text( - !loadingStatus - ? widget.videoDetail!.title - : videoItem['title'], - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 20), - SizedBox( - width: 34, - height: 34, - child: IconButton( - style: ButtonStyle( - padding: - MaterialStateProperty.all(EdgeInsets.zero), - backgroundColor: - MaterialStateProperty.resolveWith((states) { - return t.highlightColor.withOpacity(0.2); - }), - ), - onPressed: showIntroDetail, - icon: Icon( - Icons.more_horiz, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ], + child: Text( + !loadingStatus + ? widget.videoDetail!.title + : videoItem['title'], + style: const TextStyle( + fontSize: 18, + 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, + 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), + ), + ) + ], ), - const SizedBox(height: 7), // 点赞收藏转发 布局样式1 - SingleChildScrollView( - padding: const EdgeInsets.only(top: 7, bottom: 7), - scrollDirection: Axis.horizontal, - child: actionRow( - context, - videoIntroController, - videoDetailCtr, - ), - ), + // SingleChildScrollView( + // padding: const EdgeInsets.only(top: 7, bottom: 7), + // scrollDirection: Axis.horizontal, + // child: actionRow( + // context, + // videoIntroController, + // videoDetailCtr, + // ), + // ), // 点赞收藏转发 布局样式2 - // actionGrid(context, videoIntroController), + actionGrid(context, videoIntroController), // 合集 if (!loadingStatus && widget.videoDetail!.ugcSeason != null) ...[ @@ -452,7 +465,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { Widget actionGrid(BuildContext context, videoIntroController) { return LayoutBuilder(builder: (context, constraints) { return Container( - padding: const EdgeInsets.only(top: 6, bottom: 10), + margin: const EdgeInsets.only(top: 6, bottom: 4), height: constraints.maxWidth / 5 * 0.8, child: GridView.count( primary: false, @@ -471,12 +484,12 @@ class _VideoInfoState extends State with TickerProviderStateMixin { ? widget.videoDetail!.stat!.like!.toString() : '-'), ), - ActionItem( - icon: const Icon(FontAwesomeIcons.clock), - onTap: () => videoIntroController.actionShareVideo(), - selectStatus: false, - loadingStatus: loadingStatus, - text: '稍后再看'), + // ActionItem( + // icon: const Icon(FontAwesomeIcons.clock), + // onTap: () => videoIntroController.actionShareVideo(), + // selectStatus: false, + // loadingStatus: loadingStatus, + // text: '稍后再看'), Obx( () => ActionItem( icon: const Icon(FontAwesomeIcons.b), @@ -492,22 +505,28 @@ class _VideoInfoState extends State with TickerProviderStateMixin { () => ActionItem( icon: const Icon(FontAwesomeIcons.star), selectIcon: const Icon(FontAwesomeIcons.solidStar), - // onTap: () => videoIntroController.actionFavVideo(), onTap: () => showFavBottomSheet(), + onLongPress: () => showFavBottomSheet(type: 'longPress'), selectStatus: videoIntroController.hasFav.value, loadingStatus: loadingStatus, text: !loadingStatus ? widget.videoDetail!.stat!.favorite!.toString() : '-'), ), + ActionItem( + icon: const Icon(FontAwesomeIcons.comment), + onTap: () => videoDetailCtr.tabCtr.animateTo(1), + selectStatus: false, + loadingStatus: loadingStatus, + text: !loadingStatus + ? widget.videoDetail!.stat!.reply!.toString() + : '评论'), ActionItem( icon: const Icon(FontAwesomeIcons.shareFromSquare), onTap: () => videoIntroController.actionShareVideo(), selectStatus: false, loadingStatus: loadingStatus, - text: !loadingStatus - ? widget.videoDetail!.stat!.share!.toString() - : '-'), + text: '分享'), ], ), ); diff --git a/lib/pages/video/detail/introduction/widgets/action_item.dart b/lib/pages/video/detail/introduction/widgets/action_item.dart index a2e54b33..95ac103b 100644 --- a/lib/pages/video/detail/introduction/widgets/action_item.dart +++ b/lib/pages/video/detail/introduction/widgets/action_item.dart @@ -6,6 +6,7 @@ class ActionItem extends StatelessWidget { final Icon? icon; final Icon? selectIcon; final Function? onTap; + final Function? onLongPress; final bool? loadingStatus; final String? text; final bool selectStatus; @@ -15,6 +16,7 @@ class ActionItem extends StatelessWidget { this.icon, this.selectIcon, this.onTap, + this.onLongPress, this.loadingStatus, this.text, this.selectStatus = false, @@ -27,6 +29,9 @@ class ActionItem extends StatelessWidget { feedBack(), onTap!(), }, + onLongPress: () => { + if (onLongPress != null) {onLongPress!()} + }, borderRadius: StyleString.mdRadius, child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/pages/video/detail/introduction/widgets/group_panel.dart b/lib/pages/video/detail/introduction/widgets/group_panel.dart new file mode 100644 index 00000000..0a105f9d --- /dev/null +++ b/lib/pages/video/detail/introduction/widgets/group_panel.dart @@ -0,0 +1,156 @@ +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/common/widgets/http_error.dart'; +import 'package:pilipala/http/member.dart'; +import 'package:pilipala/models/member/tags.dart'; +import 'package:pilipala/utils/feed_back.dart'; +import 'package:pilipala/utils/storage.dart'; + +class GroupPanel extends StatefulWidget { + final int? mid; + const GroupPanel({super.key, this.mid}); + + @override + State createState() => _GroupPanelState(); +} + +class _GroupPanelState extends State { + Box localCache = GStrorage.localCache; + late double sheetHeight; + late Future _futureBuilderFuture; + late List tagsList; + bool showDefault = true; + + @override + void initState() { + super.initState(); + sheetHeight = localCache.get('sheetHeight'); + _futureBuilderFuture = MemberHttp.followUpTags(); + } + + void onSave() async { + feedBack(); + // 是否有选中的 有选中的带id,没选使用默认0 + bool anyHasChecked = tagsList.any((e) => e.checked == true); + late String tagids; + if (anyHasChecked) { + List checkedList = tagsList.where((e) => e.checked == true).toList(); + List tagidList = checkedList.map((e) => e.tagid).toList(); + tagids = tagidList.join(','); + } else { + tagids = '0'; + } + // 保存 + var res = await MemberHttp.addUsers(widget.mid, tagids); + SmartDialog.showToast(res['msg']); + if (res['status']) { + Get.back(); + } + } + + @override + Widget build(BuildContext context) { + return Container( + height: sheetHeight, + color: Theme.of(context).colorScheme.background, + child: Column( + children: [ + AppBar( + centerTitle: false, + elevation: 0, + leading: IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.close_outlined)), + title: + Text('设置关注分组', style: Theme.of(context).textTheme.titleMedium), + ), + Expanded( + child: Material( + child: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + tagsList = data['data']; + return ListView.builder( + itemCount: data['data'].length, + itemBuilder: (context, index) { + return ListTile( + onTap: () { + data['data'][index].checked = + !data['data'][index].checked; + showDefault = + !data['data'].any((e) => e.checked == true); + setState(() {}); + }, + dense: true, + leading: const Icon(Icons.group_outlined), + minLeadingWidth: 0, + title: Text(data['data'][index].name), + subtitle: data['data'][index].tip != '' + ? Text(data['data'][index].tip) + : null, + trailing: Transform.scale( + scale: 0.9, + child: Checkbox( + value: data['data'][index].checked, + onChanged: (bool? checkValue) { + data['data'][index].checked = checkValue; + showDefault = !data['data'] + .any((e) => e.checked == true); + setState(() {}); + }, + ), + ), + ); + }, + ); + } else { + return HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ); + } + } else { + // 骨架屏 + return const Text('请求中'); + } + }, + ), + ), + ), + Divider( + height: 1, + color: Theme.of(context).disabledColor.withOpacity(0.08), + ), + Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(context).padding.bottom + 12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => onSave(), + style: TextButton.styleFrom( + padding: const EdgeInsets.only(left: 30, right: 30), + foregroundColor: Theme.of(context).colorScheme.onPrimary, + backgroundColor: + Theme.of(context).colorScheme.primary, // 设置按钮背景色 + ), + child: Text(showDefault ? '保存至默认分组' : '保存'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/video/detail/introduction/widgets/intro_detail.dart b/lib/pages/video/detail/introduction/widgets/intro_detail.dart index d9846b19..1db23a1d 100644 --- a/lib/pages/video/detail/introduction/widgets/intro_detail.dart +++ b/lib/pages/video/detail/introduction/widgets/intro_detail.dart @@ -1,5 +1,6 @@ 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/common/widgets/stat/danmu.dart'; @@ -129,7 +130,50 @@ class IntroDetail extends StatelessWidget { final currentDesc = descV2[index]; switch (currentDesc.type) { case 1: - return TextSpan(text: currentDesc.rawText); + 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); diff --git a/lib/pages/video/detail/introduction/widgets/menu_row.dart b/lib/pages/video/detail/introduction/widgets/menu_row.dart index 91af6fba..6f9cf51b 100644 --- a/lib/pages/video/detail/introduction/widgets/menu_row.dart +++ b/lib/pages/video/detail/introduction/widgets/menu_row.dart @@ -17,35 +17,31 @@ class MenuRow extends StatelessWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: [ - actionRowLineItem( - context, - () => {}, - loadingStatus, - '推荐', - selectStatus: true, - ), - const SizedBox(width: 8), - actionRowLineItem( - context, - () => {}, - loadingStatus, - '弹幕', + ActionRowLineItem( + onTap: () => {}, + loadingStatus: loadingStatus, + text: '推荐', selectStatus: false, ), const SizedBox(width: 8), - actionRowLineItem( - context, - () => {}, - loadingStatus, - '评论列表', + ActionRowLineItem( + onTap: () => {}, + loadingStatus: loadingStatus, + text: '弹幕', selectStatus: false, ), const SizedBox(width: 8), - actionRowLineItem( - context, - () => {}, - loadingStatus, - '播放列表', + ActionRowLineItem( + onTap: () => {}, + loadingStatus: loadingStatus, + text: '评论列表', + selectStatus: false, + ), + const SizedBox(width: 8), + ActionRowLineItem( + onTap: () => {}, + loadingStatus: loadingStatus, + text: '播放列表', selectStatus: false, ), ]), @@ -99,3 +95,62 @@ class MenuRow extends StatelessWidget { ); } } + +class ActionRowLineItem extends StatelessWidget { + final bool? selectStatus; + final Function? onTap; + final bool? loadingStatus; + final String? text; + + const ActionRowLineItem( + {super.key, + this.selectStatus, + this.onTap, + this.text, + this.loadingStatus = false}); + + @override + Widget build(BuildContext context) { + return Material( + color: selectStatus! + ? Theme.of(context).colorScheme.secondaryContainer + : Colors.transparent, + borderRadius: const BorderRadius.all(Radius.circular(30)), + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () => { + feedBack(), + onTap!(), + }, + 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.of(context).colorScheme.secondaryContainer, + ), + ), + 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.of(context).colorScheme.onSecondaryContainer + : Theme.of(context).colorScheme.outline), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/detail/introduction/widgets/page.dart b/lib/pages/video/detail/introduction/widgets/page.dart index 261b6227..b3f1f7a0 100644 --- a/lib/pages/video/detail/introduction/widgets/page.dart +++ b/lib/pages/video/detail/introduction/widgets/page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/models/video_detail_res.dart'; +import 'package:pilipala/pages/video/detail/index.dart'; class PagesPanel extends StatefulWidget { final List pages; @@ -22,13 +23,23 @@ class PagesPanel extends StatefulWidget { class _PagesPanelState extends State { late List episodes; + late int cid; late int currentIndex; + String heroTag = Get.arguments['heroTag']; + late VideoDetailController _videoDetailController; @override void initState() { super.initState(); + cid = widget.cid!; episodes = widget.pages; - currentIndex = episodes.indexWhere((e) => e.cid == widget.cid); + _videoDetailController = Get.find(tag: heroTag); + currentIndex = episodes.indexWhere((e) => e.cid == cid); + _videoDetailController.cid.listen((p0) { + cid = p0; + setState(() {}); + currentIndex = episodes.indexWhere((e) => e.cid == cid); + }); } void changeFucCall(item, i) async { diff --git a/lib/pages/video/detail/introduction/widgets/season.dart b/lib/pages/video/detail/introduction/widgets/season.dart index 3f3a1475..611b9d70 100644 --- a/lib/pages/video/detail/introduction/widgets/season.dart +++ b/lib/pages/video/detail/introduction/widgets/season.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/models/video_detail_res.dart'; +import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/utils/id_utils.dart'; class SeasonPanel extends StatefulWidget { @@ -23,11 +24,16 @@ class SeasonPanel extends StatefulWidget { class _SeasonPanelState extends State { late List episodes; + late int cid; late int currentIndex; + String heroTag = Get.arguments['heroTag']; + late VideoDetailController _videoDetailController; @override void initState() { super.initState(); + cid = widget.cid!; + _videoDetailController = Get.find(tag: heroTag); /// 根据 cid 找到对应集,找到对应 episodes /// 有多个episodes时,只显示其中一个 @@ -36,7 +42,7 @@ class _SeasonPanelState extends State { for (int i = 0; i < sections.length; i++) { List episodesList = sections[i].episodes!; for (int j = 0; j < episodesList.length; j++) { - if (episodesList[j].cid == widget.cid) { + if (episodesList[j].cid == cid) { episodes = episodesList; continue; } @@ -47,7 +53,12 @@ class _SeasonPanelState extends State { // episodes = widget.ugcSeason.sections! // .firstWhere((e) => e.seasonId == widget.ugcSeason.id) // .episodes!; - currentIndex = episodes.indexWhere((e) => e.cid == widget.cid); + currentIndex = episodes.indexWhere((e) => e.cid == cid); + _videoDetailController.cid.listen((p0) { + cid = p0; + setState(() {}); + currentIndex = episodes.indexWhere((e) => e.cid == cid); + }); } void changeFucCall(item, i) async { @@ -57,7 +68,9 @@ class _SeasonPanelState extends State { item.aid, ); currentIndex = i; + setState(() {}); Get.back(); + setState(() {}); } @override diff --git a/lib/pages/video/detail/related/view.dart b/lib/pages/video/detail/related/view.dart index 73c6e289..e26df4d9 100644 --- a/lib/pages/video/detail/related/view.dart +++ b/lib/pages/video/detail/related/view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/skeleton/video_card_h.dart'; import 'package:pilipala/common/widgets/animated_dialog.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/overlay_pop.dart'; import 'package:pilipala/common/widgets/video_card_h.dart'; import './controller.dart'; @@ -22,6 +23,9 @@ class _RelatedVideoPanelState extends State { future: _releatedController.queryRelatedVideo(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } if (snapshot.data!['status']) { // 请求成功 return SliverList( @@ -51,9 +55,7 @@ class _RelatedVideoPanelState extends State { }, childCount: snapshot.data['data'].length + 1)); } else { // 请求错误 - return const Center( - child: Text('出错了'), - ); + return HttpError(errMsg: '出错了', fn: () {}); } } else { // 骨架屏 diff --git a/lib/pages/video/detail/reply/controller.dart b/lib/pages/video/detail/reply/controller.dart index 207eb856..40c26875 100644 --- a/lib/pages/video/detail/reply/controller.dart +++ b/lib/pages/video/detail/reply/controller.dart @@ -92,12 +92,12 @@ class VideoReplyController extends GetxController { } } replies.insertAll(0, res['data'].topReplies); + count.value = res['data'].page.count; replyList.value = replies; } else { replyList.addAll(replies); } } - count.value = res['data'].page.count; isLoadingMore = false; return res; } diff --git a/lib/pages/video/detail/reply/view.dart b/lib/pages/video/detail/reply/view.dart index 7b960014..827fc3f9 100644 --- a/lib/pages/video/detail/reply/view.dart +++ b/lib/pages/video/detail/reply/view.dart @@ -39,6 +39,7 @@ class _VideoReplyPanelState extends State Future? _futureBuilderFuture; bool _isFabVisible = true; String replyLevel = '1'; + late String heroTag; // 添加页面缓存 @override @@ -46,22 +47,29 @@ class _VideoReplyPanelState extends State @override void initState() { - int oid = widget.bvid != null ? IdUtils.bv2av(widget.bvid!) : 0; super.initState(); + int oid = widget.bvid != null ? IdUtils.bv2av(widget.bvid!) : 0; + heroTag = Get.arguments['heroTag']; replyLevel = widget.replyLevel ?? '1'; if (replyLevel == '2') { _videoReplyController = Get.put( VideoReplyController(oid, widget.rpid.toString(), replyLevel), tag: widget.rpid.toString()); } else { - _videoReplyController = Get.put(VideoReplyController(oid, '', replyLevel), - tag: Get.arguments['heroTag']); + _videoReplyController = + Get.put(VideoReplyController(oid, '', replyLevel), tag: heroTag); } fabAnimationCtr = AnimationController( vsync: this, duration: const Duration(milliseconds: 300)); _futureBuilderFuture = _videoReplyController.queryReplyList(); + + fabAnimationCtr.forward(); + scrollListener(); + } + + void scrollListener() { scrollController = _videoReplyController.scrollController; scrollController.addListener( () { @@ -81,7 +89,6 @@ class _VideoReplyPanelState extends State } }, ); - fabAnimationCtr.forward(); } void _showFab() { @@ -101,7 +108,7 @@ class _VideoReplyPanelState extends State // 展示二级回复 void replyReply(replyItem) { VideoDetailController videoDetailCtr = - Get.find(tag: Get.arguments['heroTag']); + Get.find(tag: heroTag); if (replyItem != null) { videoDetailCtr.oid = replyItem.oid; videoDetailCtr.fRpid = replyItem.rpid!; @@ -112,9 +119,10 @@ class _VideoReplyPanelState extends State @override void dispose() { - super.dispose(); + scrollController.removeListener(() {}); fabAnimationCtr.dispose(); scrollController.dispose(); + super.dispose(); } @override @@ -128,7 +136,7 @@ class _VideoReplyPanelState extends State child: Stack( children: [ CustomScrollView( - controller: _videoReplyController.scrollController, + controller: scrollController, key: const PageStorageKey('评论'), slivers: [ SliverPersistentHeader( @@ -187,7 +195,7 @@ class _VideoReplyPanelState extends State future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data as Map; + var data = snapshot.data; if (data['status']) { // 请求成功 return Obx( diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index df51b118..58acd8ab 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -7,9 +7,11 @@ import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/models/video/reply/item.dart'; +import 'package:pilipala/pages/preview/index.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/pages/video/detail/replyNew/index.dart'; import 'package:pilipala/utils/feed_back.dart'; +import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; @@ -666,46 +668,71 @@ InlineSpan buildContent( // 匹配 jumpUrl String matchUrl = matchMember; if (content.jumpUrl.isNotEmpty && hasMatchMember) { - List urlKeys = content.jumpUrl.keys.toList(); + List urlKeys = content.jumpUrl.keys.toList().reversed.toList(); + for (var index = 0; index < urlKeys.length; index++) { + var i = urlKeys[index]; + if (i.contains('?')) { + urlKeys[index] = i.replaceAll('?', '\\?'); + } + } matchUrl = matchMember.splitMapJoin( /// RegExp.escape() 转义特殊字符 - RegExp(RegExp.escape(urlKeys.join("|"))), + RegExp(urlKeys.map((key) => key).join("|")), + // RegExp('What does the fox say\\?'), onMatch: (Match match) { String matchStr = match[0]!; - String appUrlSchema = content.jumpUrl[matchStr]['app_url_schema']; + String appUrlSchema = ''; + if (content.jumpUrl[matchStr] != null) { + appUrlSchema = content.jumpUrl[matchStr]['app_url_schema']; + } // 默认不显示关键词 bool enableWordRe = setting.get(SettingBoxKey.enableWordRe, defaultValue: false); - spanChilds.add( - TextSpan( - text: content.jumpUrl[matchStr]['title'], - style: TextStyle( - color: enableWordRe - ? Theme.of(context).colorScheme.primary - : null, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - if (appUrlSchema == '') { - Get.toNamed( - '/webview', - parameters: { - 'url': matchStr, - 'type': 'url', - 'pageTitle': '' - }, - ); - } else { - if (appUrlSchema.startsWith('bilibili://search') && - enableWordRe) { - Get.toNamed('/searchResult', parameters: { - 'keyword': content.jumpUrl[matchStr]['title'] - }); + if (content.jumpUrl[matchStr] != null) { + spanChilds.add( + TextSpan( + text: content.jumpUrl[matchStr]['title'], + style: TextStyle( + color: enableWordRe + ? Theme.of(context).colorScheme.primary + : null, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + if (appUrlSchema == '') { + String str = Uri.parse(matchStr).pathSegments[0]; + Map matchRes = IdUtils.matchAvorBv(input: str); + List matchKeys = matchRes.keys.toList(); + if (matchKeys.isNotEmpty) { + if (matchKeys.first == 'BV') { + Get.toNamed( + '/searchResult', + parameters: {'keyword': matchRes['BV']}, + ); + } + } else { + Get.toNamed( + '/webview', + parameters: { + 'url': matchStr, + 'type': 'url', + 'pageTitle': '' + }, + ); + } + } else { + if (appUrlSchema.startsWith('bilibili://search') && + enableWordRe) { + Get.toNamed('/searchResult', parameters: { + 'keyword': content.jumpUrl[matchStr]['title'] + }); + } } - } - }, - ), - ); + }, + ), + ); + } + if (appUrlSchema.startsWith('bilibili://search') && enableWordRe) { spanChilds.add( WidgetSpan( @@ -743,11 +770,14 @@ InlineSpan buildContent( recognizer: TapGestureRecognizer() ..onTap = () { // 跳转到指定位置 - Get.find(tag: Get.arguments['heroTag']) - .plPlayerController - .seekTo( - Duration(seconds: Utils.duration(matchStr)), - ); + try { + Get.find( + tag: Get.arguments['heroTag']) + .plPlayerController + .seekTo( + Duration(seconds: Utils.duration(matchStr)), + ); + } catch (_) {} }, ), ); @@ -773,7 +803,7 @@ InlineSpan buildContent( // 图片渲染 if (content.pictures.isNotEmpty) { - List picList = []; + List picList = []; int len = content.pictures.length; if (len == 1) { Map pictureItem = content.pictures.first; @@ -785,8 +815,13 @@ InlineSpan buildContent( builder: (context, BoxConstraints box) { return GestureDetector( onTap: () { - Get.toNamed('/preview', - arguments: {'initialPage': 0, 'imgList': picList}); + showDialog( + useSafeArea: false, + context: context, + builder: (context) { + return ImagePreview(initialPage: 0, imgList: picList); + }, + ); }, child: Padding( padding: const EdgeInsets.only(top: 4), @@ -814,8 +849,13 @@ InlineSpan buildContent( builder: (context, BoxConstraints box) { return GestureDetector( onTap: () { - Get.toNamed('/preview', - arguments: {'initialPage': i, 'imgList': picList}); + showDialog( + useSafeArea: false, + context: context, + builder: (context) { + return ImagePreview(initialPage: i, imgList: picList); + }, + ); }, child: NetworkImgLayer( src: content.pictures[i]['img_src'], diff --git a/lib/pages/video/detail/replyReply/controller.dart b/lib/pages/video/detail/replyReply/controller.dart index bd5cfd1f..6ce3722f 100644 --- a/lib/pages/video/detail/replyReply/controller.dart +++ b/lib/pages/video/detail/replyReply/controller.dart @@ -26,11 +26,6 @@ class VideoReplyReplyController extends GetxController { currentPage = 0; } - // 上拉加载 - Future onLoad() async { - queryReplyList(type: 'onLoad'); - } - Future queryReplyList({type = 'init'}) async { if (type == 'init') { currentPage = 0; @@ -49,11 +44,11 @@ class VideoReplyReplyController extends GetxController { if (replyList.length == res['data'].page.count) { noMore.value = '没有更多了'; } + currentPage++; } else { // 未登录状态replies可能返回null noMore.value = currentPage == 0 ? '还没有评论' : '没有更多了'; } - currentPage++; if (type == 'init') { // List replies = res['data'].replies; // 添加置顶回复 @@ -72,6 +67,10 @@ class VideoReplyReplyController extends GetxController { // res['data'].replies = replies; replyList.value = replies; } else { + // 每次回复之后,翻页请求有且只有相同的一条回复数据 + if (replies.length == 1 && replies.last.rpid == replyList.last.rpid) { + return; + } replyList.addAll(replies); // res['data'].replies.addAll(replyList); } diff --git a/lib/pages/video/detail/replyReply/view.dart b/lib/pages/video/detail/replyReply/view.dart index 0d86cb5b..f2a72faf 100644 --- a/lib/pages/video/detail/replyReply/view.dart +++ b/lib/pages/video/detail/replyReply/view.dart @@ -1,3 +1,4 @@ +import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; @@ -54,9 +55,9 @@ class _VideoReplyReplyPanelState extends State { () { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 300) { - if (!_videoReplyReplyController.isLoadingMore) { - _videoReplyReplyController.onLoad(); - } + EasyThrottle.throttle('replylist', const Duration(seconds: 2), () { + _videoReplyReplyController.queryReplyList(type: 'onLoad'); + }); } }, ); diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 3dd25fca..531dc876 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -1,25 +1,26 @@ import 'dart:async'; +import 'dart:io'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:floating/floating.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/common/widgets/sliver_header.dart'; import 'package:pilipala/http/user.dart'; import 'package:pilipala/models/common/search_type.dart'; import 'package:pilipala/pages/bangumi/introduction/index.dart'; import 'package:pilipala/pages/danmaku/view.dart'; -import 'package:pilipala/pages/video/detail/introduction/widgets/menu_row.dart'; import 'package:pilipala/pages/video/detail/reply/index.dart'; import 'package:pilipala/pages/video/detail/controller.dart'; import 'package:pilipala/pages/video/detail/introduction/index.dart'; import 'package:pilipala/pages/video/detail/related/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; +import 'package:pilipala/services/service_locator.dart'; import 'package:pilipala/utils/storage.dart'; -import 'widgets/app_bar.dart'; import 'widgets/header_control.dart'; class VideoDetailPage extends StatefulWidget { @@ -32,14 +33,14 @@ class VideoDetailPage extends StatefulWidget { } class _VideoDetailPageState extends State - with TickerProviderStateMixin, RouteAware { - final VideoDetailController videoDetailController = - Get.put(VideoDetailController(), tag: Get.arguments['heroTag']); + with TickerProviderStateMixin, RouteAware, WidgetsBindingObserver { + late VideoDetailController videoDetailController; PlPlayerController? plPlayerController; final ScrollController _extendNestCtr = ScrollController(); late StreamController appbarStream; - final VideoIntroController videoIntroController = - Get.put(VideoIntroController(), tag: Get.arguments['heroTag']); + late VideoIntroController videoIntroController; + late BangumiIntroController bangumiIntroController; + late String heroTag; PlayerStatus playerStatus = PlayerStatus.playing; double doubleOffset = 0; @@ -51,15 +52,39 @@ class _VideoDetailPageState extends State late Future _futureBuilderFuture; // 自动退出全屏 late bool autoExitFullcreen; + late bool autoPlayEnable; + late bool autoPiP; + final floating = Floating(); @override void initState() { super.initState(); + heroTag = Get.arguments['heroTag']; + videoDetailController = Get.put(VideoDetailController(), tag: heroTag); + videoIntroController = Get.put(VideoIntroController(), tag: heroTag); + videoIntroController.videoDetail.listen((value) { + videoPlayerServiceHandler.onVideoDetailChange( + value, videoDetailController.cid.value); + }); + bangumiIntroController = Get.put(BangumiIntroController(), tag: heroTag); + bangumiIntroController.bangumiDetail.listen((value) { + videoPlayerServiceHandler.onVideoDetailChange( + value, videoDetailController.cid.value); + }); + videoDetailController.cid.listen((p0) { + videoPlayerServiceHandler.onVideoDetailChange( + bangumiIntroController.bangumiDetail.value, p0); + }); statusBarHeight = localCache.get('statusBarHeight'); autoExitFullcreen = setting.get(SettingBoxKey.enableAutoExit, defaultValue: false); + autoPlayEnable = + setting.get(SettingBoxKey.autoPlayEnable, defaultValue: true); + autoPiP = setting.get(SettingBoxKey.autoPiP, defaultValue: false); + videoSourceInit(); appbarStreamListen(); + WidgetsBinding.instance.addObserver(this); } // 获取视频资源,初始化播放器 @@ -83,15 +108,38 @@ class _VideoDetailPageState extends State } // 播放器状态监听 - void playerListener(PlayerStatus? status) { + void playerListener(PlayerStatus? status) async { playerStatus = status!; if (status == PlayerStatus.completed) { // 结束播放退出全屏 if (autoExitFullcreen) { plPlayerController!.triggerFullScreen(status: false); } + + /// 顺序播放 列表循环 + if (plPlayerController!.playRepeat != PlayRepeat.pause && + plPlayerController!.playRepeat != PlayRepeat.singleCycle) { + if (videoDetailController.videoType == SearchType.video) { + videoIntroController.nextPlay(); + } + if (videoDetailController.videoType == SearchType.media_bangumi) { + bangumiIntroController.nextPlay(); + } + } + + /// 单个循环 + if (plPlayerController!.playRepeat == PlayRepeat.singleCycle) { + plPlayerController!.seekTo(Duration.zero); + plPlayerController!.play(); + } // 播放完展示控制栏 - plPlayerController!.onLockControl(false); + try { + PiPStatus currentStatus = + await videoDetailController.floating!.pipStatus; + if (currentStatus == PiPStatus.disabled) { + plPlayerController!.onLockControl(false); + } + } catch (_) {} } } @@ -102,6 +150,7 @@ class _VideoDetailPageState extends State plPlayerController!.play(); } + /// 未开启自动播放时触发播放 Future handlePlay() async { await videoDetailController.playerInit(); plPlayerController = videoDetailController.plPlayerController; @@ -111,8 +160,16 @@ class _VideoDetailPageState extends State @override void dispose() { - plPlayerController!.removeStatusLister(playerListener); - plPlayerController!.dispose(); + if (plPlayerController != null) { + plPlayerController!.removeStatusLister(playerListener); + plPlayerController!.dispose(); + } + if (videoDetailController.floating != null) { + videoDetailController.floating!.dispose(); + } + videoPlayerServiceHandler.onVideoDetailDispose(); + WidgetsBinding.instance.removeObserver(this); + floating.dispose(); super.dispose(); } @@ -123,10 +180,12 @@ class _VideoDetailPageState extends State if (setting.get(SettingBoxKey.enableAutoBrightness, defaultValue: false)) { videoDetailController.brightness = plPlayerController!.brightness.value; } - videoDetailController.defaultST = plPlayerController!.position.value; - videoIntroController.isPaused = true; - plPlayerController!.removeStatusLister(playerListener); - plPlayerController!.pause(); + if (plPlayerController != null) { + videoDetailController.defaultST = plPlayerController!.position.value; + videoIntroController.isPaused = true; + plPlayerController!.removeStatusLister(playerListener); + plPlayerController!.pause(); + } super.didPushNext(); } @@ -134,13 +193,19 @@ class _VideoDetailPageState extends State // 返回当前页面时 void didPopNext() async { videoDetailController.isFirstTime = false; - videoDetailController.playerInit(); + bool autoplay = autoPlayEnable; + videoDetailController.playerInit(autoplay: autoplay); + + /// 未开启自动播放时,未播放跳转下一页返回/播放后跳转下一页返回 + videoDetailController.autoPlay.value = + !videoDetailController.isShowCover.value; videoIntroController.isPaused = false; - if (_extendNestCtr.position.pixels == 0) { + if (_extendNestCtr.position.pixels == 0 && autoplay) { await Future.delayed(const Duration(milliseconds: 300)); - plPlayerController!.play(); + plPlayerController!.seekTo(videoDetailController.defaultST); + plPlayerController?.play(); } - plPlayerController!.addStatusLister(playerListener); + plPlayerController?.addStatusLister(playerListener); super.didPopNext(); } @@ -151,12 +216,23 @@ class _VideoDetailPageState extends State .subscribe(this, ModalRoute.of(context) as PageRoute); } + @override + void didChangeAppLifecycleState(AppLifecycleState lifecycleState) { + if (lifecycleState == AppLifecycleState.inactive && autoPiP) { + floating.enable( + aspectRatio: Rational( + videoDetailController.data.dash!.video!.first.width!, + videoDetailController.data.dash!.video!.first.height!, + )); + } + } + @override Widget build(BuildContext context) { final videoHeight = MediaQuery.of(context).size.width * 9 / 16; final double pinnedHeaderHeight = statusBarHeight + kToolbarHeight + videoHeight; - return SafeArea( + Widget childWhenDisabled = SafeArea( top: false, bottom: false, child: Stack( @@ -198,12 +274,9 @@ class _VideoDetailPageState extends State ? const SizedBox() : PLVideoPlayer( controller: plPlayerController!, - headerControl: HeaderControl( - controller: - plPlayerController, - videoDetailCtr: - videoDetailController, - ), + headerControl: + videoDetailController + .headerControl, danmuWidget: Obx( () => PlDanmaku( key: Key( @@ -336,13 +409,18 @@ class _VideoDetailPageState extends State ), ]; }, + // pinnedHeaderSliverHeightBuilder: () { + // return playerStatus != PlayerStatus.playing + // ? statusBarHeight + kToolbarHeight + // : pinnedHeaderHeight; + // }, + /// 不收回 pinnedHeaderSliverHeightBuilder: () { - return playerStatus != PlayerStatus.playing - ? statusBarHeight + kToolbarHeight - : pinnedHeaderHeight; + return pinnedHeaderHeight; }, onlyOneScrollInBody: true, body: Container( + key: Key(heroTag), color: Theme.of(context).colorScheme.background, child: Column( children: [ @@ -378,8 +456,8 @@ class _VideoDetailPageState extends State const VideoIntroPanel(), ] else if (videoDetailController.videoType == SearchType.media_bangumi) ...[ - BangumiIntroPanel( - cid: videoDetailController.cid) + Obx(() => BangumiIntroPanel( + cid: videoDetailController.cid.value)), ], // if (videoDetailController.videoType == // SearchType.video) ...[ @@ -418,21 +496,61 @@ class _VideoDetailPageState extends State ), ), ), + + /// 重新进入会刷新 // 播放完成/暂停播放 - StreamBuilder( - stream: appbarStream.stream, - initialData: 0, - builder: ((context, snapshot) { - return ScrollAppBar( - snapshot.data!.toDouble(), - () => continuePlay(), - playerStatus, - null, - ); - }), - ) + // StreamBuilder( + // stream: appbarStream.stream, + // initialData: 0, + // builder: ((context, snapshot) { + // return ScrollAppBar( + // snapshot.data!.toDouble(), + // () => continuePlay(), + // playerStatus, + // null, + // ); + // }), + // ) ], ), ); + Widget childWhenEnabled = FutureBuilder( + key: Key(heroTag), + future: _futureBuilderFuture, + builder: ((context, snapshot) { + if (snapshot.hasData && snapshot.data['status']) { + return Obx( + () => !videoDetailController.autoPlay.value + ? const SizedBox() + : PLVideoPlayer( + controller: plPlayerController!, + headerControl: HeaderControl( + controller: plPlayerController, + videoDetailCtr: videoDetailController, + ), + danmuWidget: Obx( + () => PlDanmaku( + key: Key( + videoDetailController.danmakuCid.value.toString()), + cid: videoDetailController.danmakuCid.value, + playerController: plPlayerController!, + ), + ), + ), + ); + } else { + return const SizedBox(); + } + }), + ); + if (Platform.isAndroid) { + return PiPSwitcher( + childWhenDisabled: childWhenDisabled, + childWhenEnabled: childWhenEnabled, + floating: floating, + ); + } else { + return childWhenDisabled; + } } } 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/pages/video/detail/widgets/app_bar.dart b/lib/pages/video/detail/widgets/app_bar.dart index e21b4799..fb7822fb 100644 --- a/lib/pages/video/detail/widgets/app_bar.dart +++ b/lib/pages/video/detail/widgets/app_bar.dart @@ -48,15 +48,15 @@ class ScrollAppBar extends StatelessWidget { ], ), ), - actions: [ - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.share, - size: 20, - )), - const SizedBox(width: 12) - ], + // actions: [ + // IconButton( + // onPressed: () {}, + // icon: const Icon( + // Icons.share, + // size: 20, + // )), + // const SizedBox(width: 12) + // ], ), ), ), diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index 968bbac0..ee5f2c0f 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -1,18 +1,29 @@ +import 'dart:io'; + +import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:ns_danmaku/ns_danmaku.dart'; import 'package:pilipala/models/video/play/quality.dart'; import 'package:pilipala/models/video/play/url.dart'; import 'package:pilipala/pages/video/detail/index.dart'; +import 'package:pilipala/pages/video/detail/introduction/widgets/menu_row.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; +import 'package:pilipala/utils/storage.dart'; class HeaderControl extends StatefulWidget implements PreferredSizeWidget { final PlPlayerController? controller; final VideoDetailController? videoDetailCtr; + final Floating? floating; const HeaderControl({ this.controller, this.videoDetailCtr, + this.floating, Key? key, }) : super(key: key); @@ -29,11 +40,16 @@ class _HeaderControlState extends State { TextStyle subTitleStyle = const TextStyle(fontSize: 12); TextStyle titleStyle = const TextStyle(fontSize: 14); Size get preferredSize => const Size(double.infinity, kToolbarHeight); + Box localCache = GStrorage.localCache; + Box videoStorage = GStrorage.video; + late List speedsList; + double buttonSpace = 8; @override void initState() { super.initState(); videoInfo = widget.videoDetailCtr!.data; + speedsList = widget.controller!.speedsList; } /// 设置面板 @@ -45,7 +61,7 @@ class _HeaderControlState extends State { builder: (_) { return Container( width: double.infinity, - height: 400, + height: 440, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: Theme.of(context).colorScheme.background, @@ -73,7 +89,6 @@ class _HeaderControlState extends State { Expanded( child: Material( child: ListView( - physics: const NeverScrollableScrollPhysics(), children: [ ListTile( onTap: () {}, @@ -138,17 +153,17 @@ class _HeaderControlState extends State { '当前解码格式 ${widget.videoDetailCtr!.currentDecodeFormats.description}', style: subTitleStyle), ), - // ListTile( - // onTap: () {}, - // dense: true, - // enabled: false, - // leading: const Icon(Icons.play_circle_outline, size: 20), - // title: Text('播放设置', style: titleStyle), - // ), ListTile( - onTap: () {}, + onTap: () => {Get.back(), showSetRepeat()}, + dense: true, + leading: const Icon(Icons.repeat, size: 20), + title: Text('播放顺序', style: titleStyle), + subtitle: Text(widget.controller!.playRepeat.description, + style: subTitleStyle), + ), + ListTile( + onTap: () => {Get.back(), showSetDanmaku()}, dense: true, - enabled: false, leading: const Icon(Icons.subtitles_outlined, size: 20), title: Text('弹幕设置', style: titleStyle), ), @@ -167,26 +182,38 @@ class _HeaderControlState extends State { /// 选择倍速 void showSetSpeedSheet() { double currentSpeed = widget.controller!.playbackSpeed; - SmartDialog.show( - animationType: SmartAnimationType.centerFade_otherSlide, + showDialog( + context: Get.context!, builder: (context) { return AlertDialog( title: const Text('播放速度'), - contentPadding: const EdgeInsets.fromLTRB(0, 20, 0, 20), content: StatefulBuilder(builder: (context, StateSetter setState) { - return Column( - mainAxisSize: MainAxisSize.min, + return Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 2, children: [ - Text('$currentSpeed倍'), - Slider( - min: PlaySpeed.values.first.value, - max: PlaySpeed.values.last.value, - value: currentSpeed, - divisions: PlaySpeed.values.length - 1, - label: '${currentSpeed}x', - onChanged: (double val) => - {setState(() => currentSpeed = val)}, - ) + for (var i in speedsList) ...[ + if (i == currentSpeed) ...[ + FilledButton( + onPressed: () async { + // setState(() => currentSpeed = i), + await widget.controller!.setPlaybackSpeed(i); + Get.back(); + }, + child: Text(i.toString()), + ), + ] else ...[ + FilledButton.tonal( + onPressed: () async { + // setState(() => currentSpeed = i), + await widget.controller!.setPlaybackSpeed(i); + Get.back(); + }, + child: Text(i.toString()), + ), + ] + ] ], ); }), @@ -200,10 +227,10 @@ class _HeaderControlState extends State { ), TextButton( onPressed: () async { - await SmartDialog.dismiss(); - widget.controller!.setPlaybackSpeed(currentSpeed); + await widget.controller!.setDefaultSpeed(); + Get.back(); }, - child: const Text('确定'), + child: const Text('默认速度'), ), ], ); @@ -257,7 +284,7 @@ class _HeaderControlState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text('选择画质', style: titleStyle), - const SizedBox(width: 4), + SizedBox(width: buttonSpace), Icon( Icons.info_outline, size: 16, @@ -454,6 +481,300 @@ class _HeaderControlState extends State { ); } + /// 弹幕功能 + void showSetDanmaku() async { + // 屏蔽类型 + List> blockTypesList = [ + {'value': 5, 'label': '顶部'}, + {'value': 2, 'label': '滚动'}, + {'value': 4, 'label': '底部'}, + {'value': 6, 'label': '彩色'}, + ]; + List blockTypes = widget.controller!.blockTypes; + // 显示区域 + List> showAreas = [ + {'value': 0.25, 'label': '1/4屏'}, + {'value': 0.5, 'label': '半屏'}, + {'value': 0.75, 'label': '3/4屏'}, + {'value': 1.0, 'label': '满屏'}, + ]; + double showArea = widget.controller!.showArea; + // 不透明度 + double opacityVal = widget.controller!.opacityVal; + // 字体大小 + double fontSizeVal = widget.controller!.fontSizeVal; + // 弹幕速度 + double danmakuSpeedVal = widget.controller!.danmakuSpeedVal; + + DanmakuController danmakuController = widget.controller!.danmakuController!; + await showModalBottomSheet( + context: context, + elevation: 0, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return StatefulBuilder(builder: (context, StateSetter setState) { + return Container( + width: double.infinity, + height: 580, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.only(left: 14, right: 14), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 45, + child: Center(child: Text('弹幕设置', style: titleStyle)), + ), + const SizedBox(height: 10), + const Text('按类型屏蔽'), + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 18), + child: Row( + children: [ + for (var i in blockTypesList) ...[ + ActionRowLineItem( + onTap: () async { + bool isChoose = blockTypes.contains(i['value']); + if (isChoose) { + blockTypes.remove(i['value']); + } else { + blockTypes.add(i['value']); + } + widget.controller!.blockTypes = blockTypes; + setState(() {}); + try { + DanmakuOption currentOption = + danmakuController.option; + DanmakuOption updatedOption = + currentOption.copyWith( + hideTop: blockTypes.contains(5), + hideBottom: blockTypes.contains(4), + hideScroll: blockTypes.contains(2), + // 添加或修改其他需要修改的选项属性 + ); + danmakuController.updateOption(updatedOption); + } catch (_) {} + }, + text: i['label'], + selectStatus: blockTypes.contains(i['value']), + ), + const SizedBox(width: 10), + ] + ], + ), + ), + const Text('显示区域'), + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 18), + child: Row( + children: [ + for (var i in showAreas) ...[ + ActionRowLineItem( + onTap: () { + showArea = i['value']; + widget.controller!.showArea = showArea; + setState(() {}); + try { + DanmakuOption currentOption = + danmakuController.option; + DanmakuOption updatedOption = + currentOption.copyWith(area: i['value']); + danmakuController.updateOption(updatedOption); + } catch (_) {} + }, + text: i['label'], + selectStatus: showArea == i['value'], + ), + const SizedBox(width: 10), + ] + ], + ), + ), + Text('不透明度 ${opacityVal * 100}%'), + Padding( + padding: const EdgeInsets.only( + top: 0, + bottom: 6, + left: 10, + right: 10, + ), + child: SliderTheme( + data: SliderThemeData( + trackShape: MSliderTrackShape(), + thumbColor: Theme.of(context).colorScheme.primary, + activeTrackColor: Theme.of(context).colorScheme.primary, + trackHeight: 10, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0), + ), + child: Slider( + min: 0, + max: 1, + value: opacityVal, + divisions: 10, + label: '${opacityVal * 100}%', + onChanged: (double val) { + opacityVal = val; + widget.controller!.opacityVal = opacityVal; + setState(() {}); + try { + DanmakuOption currentOption = + danmakuController.option; + DanmakuOption updatedOption = + currentOption.copyWith(opacity: val); + danmakuController.updateOption(updatedOption); + } catch (_) {} + }, + ), + ), + ), + Text('字体大小 ${(fontSizeVal * 100).toStringAsFixed(1)}%'), + Padding( + padding: const EdgeInsets.only( + top: 0, + bottom: 6, + left: 10, + right: 10, + ), + child: SliderTheme( + data: SliderThemeData( + trackShape: MSliderTrackShape(), + thumbColor: Theme.of(context).colorScheme.primary, + activeTrackColor: Theme.of(context).colorScheme.primary, + trackHeight: 10, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0), + ), + child: Slider( + min: 0.5, + max: 2.5, + value: fontSizeVal, + divisions: 20, + label: '${(fontSizeVal * 100).toStringAsFixed(1)}%', + onChanged: (double val) { + fontSizeVal = val; + widget.controller!.fontSizeVal = fontSizeVal; + setState(() {}); + try { + DanmakuOption currentOption = + danmakuController.option; + DanmakuOption updatedOption = + currentOption.copyWith( + fontSize: (15 * fontSizeVal).toDouble(), + ); + danmakuController.updateOption(updatedOption); + } catch (_) {} + }, + ), + ), + ), + Text('弹幕时长 ${danmakuSpeedVal.toString()}'), + Padding( + padding: const EdgeInsets.only( + top: 0, + bottom: 6, + left: 10, + right: 10, + ), + child: SliderTheme( + data: SliderThemeData( + trackShape: MSliderTrackShape(), + thumbColor: Theme.of(context).colorScheme.primary, + activeTrackColor: Theme.of(context).colorScheme.primary, + trackHeight: 10, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0), + ), + child: Slider( + min: 1, + max: 8, + value: danmakuSpeedVal, + divisions: 14, + label: danmakuSpeedVal.toString(), + onChanged: (double val) { + danmakuSpeedVal = val; + widget.controller!.danmakuSpeedVal = danmakuSpeedVal; + setState(() {}); + try { + DanmakuOption currentOption = + danmakuController.option; + DanmakuOption updatedOption = + currentOption.copyWith(duration: val); + danmakuController.updateOption(updatedOption); + } catch (_) {} + }, + ), + ), + ), + ], + ), + ), + ); + }); + }, + ); + } + + /// 播放顺序 + void showSetRepeat() async { + showModalBottomSheet( + context: context, + elevation: 0, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return Container( + width: double.infinity, + height: 250, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: const EdgeInsets.all(12), + child: Column( + children: [ + SizedBox( + height: 45, + child: Center(child: Text('选择播放顺序', style: titleStyle))), + Expanded( + child: Material( + child: ListView( + children: [ + for (var i in PlayRepeat.values) ...[ + ListTile( + onTap: () { + widget.controller!.setPlayRepeat(i); + Get.back(); + }, + dense: true, + contentPadding: + const EdgeInsets.only(left: 20, right: 20), + title: Text(i.description), + trailing: widget.controller!.playRepeat == i + ? Icon( + Icons.done, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + ) + ], + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { final _ = widget.controller!; @@ -480,7 +801,7 @@ class _HeaderControlState extends State { ), fuc: () => Get.back(), ), - const SizedBox(width: 4), + SizedBox(width: buttonSpace), ComBtn( icon: const Icon( FontAwesomeIcons.house, @@ -525,7 +846,40 @@ class _HeaderControlState extends State { ), ), ), - const SizedBox(width: 4), + SizedBox(width: buttonSpace), + if (Platform.isAndroid) ...[ + SizedBox( + width: 34, + height: 34, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () async { + bool canUsePiP = false; + widget.controller!.hiddenControls(false); + try { + canUsePiP = await widget.floating!.isPipAvailable; + } on PlatformException catch (_) { + canUsePiP = false; + } + if (canUsePiP) { + final aspectRatio = Rational( + widget.videoDetailCtr!.data.dash!.video!.first.width!, + widget.videoDetailCtr!.data.dash!.video!.first.height!, + ); + await widget.floating!.enable(aspectRatio: aspectRatio); + } else {} + }, + icon: const Icon( + Icons.picture_in_picture_outlined, + size: 19, + color: Colors.white, + ), + ), + ), + SizedBox(width: buttonSpace), + ], Obx( () => SizedBox( width: 45, @@ -542,7 +896,7 @@ class _HeaderControlState extends State { ), ), ), - const SizedBox(width: 4), + SizedBox(width: buttonSpace), ComBtn( icon: const Icon( FontAwesomeIcons.sliders, @@ -556,3 +910,21 @@ class _HeaderControlState extends State { ); } } + +class MSliderTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + SliderThemeData? sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + const double trackHeight = 3; + final double trackLeft = offset.dx; + final double trackTop = + offset.dy + (parentBox.size.height - trackHeight) / 2 + 4; + final double trackWidth = parentBox.size.width; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } +} diff --git a/lib/pages/webview/controller.dart b/lib/pages/webview/controller.dart index 492bf19e..fae5a5fc 100644 --- a/lib/pages/webview/controller.dart +++ b/lib/pages/webview/controller.dart @@ -11,6 +11,7 @@ import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/media/index.dart'; import 'package:pilipala/utils/cookie.dart'; import 'package:pilipala/utils/event_bus.dart'; +import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/login.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -41,7 +42,7 @@ class WebviewController extends GetxController { webviewInit() { controller - ..setUserAgent(Request().headerUa('mob')) + ..setUserAgent(Request().headerUa()) ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( @@ -50,7 +51,19 @@ class WebviewController extends GetxController { // Update loading bar. loadProgress.value = progress; }, - onPageStarted: (String url) {}, + onPageStarted: (String url) { + String str = Uri.parse(url).pathSegments[0]; + Map matchRes = IdUtils.matchAvorBv(input: str); + List matchKeys = matchRes.keys.toList(); + if (matchKeys.isNotEmpty) { + if (matchKeys.first == 'BV') { + Get.offAndToNamed( + '/searchResult', + parameters: {'keyword': matchRes['BV']}, + ); + } + } + }, // 加载完成 onUrlChange: (UrlChange urlChange) async { loadShow.value = false; diff --git a/lib/pages/webview/view.dart b/lib/pages/webview/view.dart index 301b1dfa..8edd2189 100644 --- a/lib/pages/webview/view.dart +++ b/lib/pages/webview/view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'controller.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -24,11 +25,20 @@ class _WebviewPageState extends State { style: Theme.of(context).textTheme.titleMedium, ), actions: [ - TextButton( + const SizedBox(width: 4), + IconButton( onPressed: () { _webviewController.controller.reload(); }, - child: const Text('刷新'), + icon: Icon(Icons.refresh_outlined, + color: Theme.of(context).colorScheme.primary), + ), + IconButton( + onPressed: () { + launchUrl(Uri.parse(_webviewController.url)); + }, + icon: Icon(Icons.open_in_browser_outlined, + color: Theme.of(context).colorScheme.primary), ), Obx( () => _webviewController.type.value == 'login' @@ -38,7 +48,7 @@ class _WebviewPageState extends State { ) : const SizedBox(), ), - const SizedBox(width: 10) + const SizedBox(width: 12) ], ), body: Column( diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index fe63cc3d..4d4d1a9b 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart'; @@ -13,14 +14,18 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:ns_danmaku/ns_danmaku.dart'; import 'package:pilipala/http/video.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; +import 'package:pilipala/services/service_locator.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'package:status_bar_control/status_bar_control.dart'; import 'package:universal_platform/universal_platform.dart'; // import 'package:wakelock_plus/wakelock_plus.dart'; Box videoStorage = GStrorage.video; Box setting = GStrorage.setting; +Box localCache = GStrorage.localCache; class PlPlayerController { Player? _videoPlayerController; @@ -52,6 +57,7 @@ class PlPlayerController { final Rx _playerCount = Rx(0); final Rx _playbackSpeed = 1.0.obs; + final Rx _longPressSpeed = 2.0.obs; final Rx _currentVolume = 1.0.obs; final Rx _currentBrightness = 0.0.obs; @@ -69,6 +75,7 @@ class PlPlayerController { Rx videoFitChanged = false.obs; final Rx _videoFit = Rx(BoxFit.contain); + final Rx _videoFitDesc = Rx('包含'); /// // ignore: prefer_final_fields @@ -104,6 +111,7 @@ class PlPlayerController { ]; PreferredSizeWidget? headerControl; + PreferredSizeWidget? bottomControl; Widget? danmuWidget; /// 数据加载监听 @@ -123,6 +131,9 @@ class PlPlayerController { /// 视频播放速度 double get playbackSpeed => _playbackSpeed.value; + // 长按倍速 + double get longPressSpeed => _longPressSpeed.value; + /// 视频缓冲 Rx get buffered => _buffered; Stream get onBufferedChanged => _buffered.stream; @@ -175,6 +186,7 @@ class PlPlayerController { /// 视频比例 Rx get videoFit => _videoFit; + Rx get videoFitDEsc => _videoFitDesc; /// 是否长按倍速 Rx get doubleSpeedStatus => _doubleSpeedStatus; @@ -199,12 +211,53 @@ class PlPlayerController { Rx isOpenDanmu = false.obs; // 关联弹幕控制器 DanmakuController? danmakuController; + // 弹幕相关配置 + late List blockTypes; + late double showArea; + late double opacityVal; + late double fontSizeVal; + late double danmakuSpeedVal; + late List speedsList; + // 缓存 + double? defaultDuration; + + // 播放顺序相关 + PlayRepeat playRepeat = PlayRepeat.pause; // 添加一个私有构造函数 PlPlayerController._() { _videoType = videoType; isOpenDanmu.value = setting.get(SettingBoxKey.enableShowDanmaku, defaultValue: false); + blockTypes = + localCache.get(LocalCacheKey.danmakuBlockType, defaultValue: []); + showArea = localCache.get(LocalCacheKey.danmakuShowArea, defaultValue: 0.5); + // 不透明度 + opacityVal = + localCache.get(LocalCacheKey.danmakuOpacity, defaultValue: 1.0); + // 字体大小 + fontSizeVal = + localCache.get(LocalCacheKey.danmakuFontScale, defaultValue: 1.0); + // 弹幕速度 + danmakuSpeedVal = + localCache.get(LocalCacheKey.danmakuSpeed, defaultValue: 4.0); + playRepeat = PlayRepeat.values.toList().firstWhere( + (e) => + e.value == + videoStorage.get(VideoBoxKey.playRepeat, + defaultValue: PlayRepeat.pause.value), + ); + _playbackSpeed.value = + videoStorage.get(VideoBoxKey.playSpeedDefault, defaultValue: 1.0); + _longPressSpeed.value = + videoStorage.get(VideoBoxKey.longPressSpeedDefault, defaultValue: 2.0); + List speedsListTemp = + videoStorage.get(VideoBoxKey.customSpeedsList, defaultValue: []); + speedsList = List.from(speedsListTemp); + for (var i in PlaySpeed.values) { + speedsList.add(i.value); + } + // _playerEventSubs = onPlayerStatusChanged.listen((PlayerStatus status) { // if (status == PlayerStatus.playing) { // WakelockPlus.enable(); @@ -217,14 +270,6 @@ class PlPlayerController { // 获取实例 传参 static PlPlayerController getInstance({ String videoType = 'archive', - List fits = const [ - BoxFit.contain, - BoxFit.cover, - BoxFit.fill, - BoxFit.fitHeight, - BoxFit.fitWidth, - BoxFit.scaleDown - ], }) { // 如果实例尚未创建,则创建一个新实例 _instance ??= PlPlayerController._(); @@ -262,7 +307,7 @@ class PlPlayerController { _autoPlay = autoplay; _looping = looping; // 初始化视频倍速 - _playbackSpeed.value = speed; + // _playbackSpeed.value = speed; // 初始化数据加载状态 dataStatus.status.value = DataStatus.loading; // 初始化全屏方向 @@ -277,6 +322,9 @@ class PlPlayerController { await pause(notify: false); } + if (_playerCount.value == 0) { + return; + } // 配置Player 音轨、字幕等等 _videoPlayerController = await _createVideoController( dataSource, _looping, enableHA, width, height); @@ -285,12 +333,11 @@ class PlPlayerController { // 数据加载完成 dataStatus.status.value = DataStatus.loaded; - await _initializePlayer(seekTo: seekTo); - // listen the video player events if (!_listenersInitialized) { startListeners(); } + await _initializePlayer(seekTo: seekTo); bool autoEnterFullcreen = setting.get(SettingBoxKey.enableAutoEnter, defaultValue: false); if (autoEnterFullcreen && _isFirstTime) { @@ -330,6 +377,11 @@ class PlPlayerController { ); var pp = player.platform as NativePlayer; + // 解除倍速限制 + await pp.setProperty("af", "scaletempo2=max-speed=8"); + // 音量不一致 + await pp.setProperty("volume-max", "100"); + await pp.setProperty("ao", "audiotrack,opensles"); // 音轨 if (dataSource.audioSource != '' && dataSource.audioSource != null) { @@ -359,6 +411,7 @@ class PlPlayerController { player, configuration: VideoControllerConfiguration( enableHardwareAcceleration: enableHA, + androidAttachSurfaceAfterVideoParameters: false, ), ); @@ -372,21 +425,15 @@ class PlPlayerController { Media(assetUrl, httpHeaders: dataSource.httpHeaders), play: false, ); - } else if (dataSource.type == DataSourceType.network) { - player.open( - Media(dataSource.videoSource!, httpHeaders: dataSource.httpHeaders), - play: false, - ); - // 音轨 - // player.setAudioTrack( - // AudioTrack.uri(dataSource.audioSource!), - // ); - } else { - player.open( - Media(dataSource.file!.path, httpHeaders: dataSource.httpHeaders), - play: false, - ); } + player.open( + Media(dataSource.videoSource!, httpHeaders: dataSource.httpHeaders), + play: false, + ); + // 音轨 + // player.setAudioTrack( + // AudioTrack.uri(dataSource.audioSource!), + // ); return player; } @@ -395,20 +442,22 @@ class PlPlayerController { Future _initializePlayer({ Duration seekTo = Duration.zero, }) async { + // 设置倍速 + if (_playbackSpeed.value != 1.0) { + await setPlaybackSpeed(_playbackSpeed.value); + } else { + await setPlaybackSpeed(1.0); + } + getVideoFit(); + // if (_looping) { + // await setLooping(_looping); + // } + // 跳转播放 if (seekTo != Duration.zero) { await this.seekTo(seekTo); } - // 设置倍速 - if (_playbackSpeed.value != 1.0) { - await setPlaybackSpeed(_playbackSpeed.value); - } - - // if (_looping) { - // await setLooping(_looping); - // } - // 自动播放 if (_autoPlay) { await play(); @@ -434,7 +483,9 @@ class PlPlayerController { for (var element in _statusListeners) { element(event ? PlayerStatus.playing : PlayerStatus.paused); } - makeHeartBeat(_position.value.inSeconds, type: 'status'); + if (videoPlayerController!.state.position.inSeconds != 0) { + makeHeartBeat(_position.value.inSeconds, type: 'status'); + } }), videoPlayerController!.stream.completed.listen((event) { if (event) { @@ -469,12 +520,24 @@ class PlPlayerController { }), videoPlayerController!.stream.buffering.listen((event) { isBuffering.value = event; + videoPlayerServiceHandler.onStatusChange( + playerStatus.status.value, event); }), // videoPlayerController!.stream.volume.listen((event) { // if (!mute.value && _volumeBeforeMute != event) { // _volumeBeforeMute = event / 100; // } // }), + // 媒体通知监听 + onPlayerStatusChanged.listen((event) { + videoPlayerServiceHandler.onStatusChange(event, isBuffering.value); + }), + onPositionChanged.listen((event) { + EasyThrottle.throttle( + 'mediaServicePositon', + const Duration(seconds: 1), + () => videoPlayerServiceHandler.onPositionChange(event)); + }), ], ); } @@ -495,6 +558,7 @@ class PlPlayerController { position = Duration.zero; } _position.value = position; + _heartDuration = position.inSeconds; if (duration.value.inSeconds != 0) { if (type != 'slider') { /// 拖动进度条调节时,不等待第一帧,防止抖动 @@ -505,17 +569,19 @@ class PlPlayerController { // play(); // } } else { + print('seek duration else'); _timerForSeek?.cancel(); _timerForSeek = Timer.periodic(const Duration(milliseconds: 200), (Timer t) async { //_timerForSeek = null; if (duration.value.inSeconds != 0) { + await _videoPlayerController!.stream.buffer.first; await _videoPlayerController?.seek(position); - // if (playerStatus.stopped) { + // if (playerStatus.status.value == PlayerStatus.paused) { // play(); // } t.cancel(); - //_timerForSeek = null; + _timerForSeek = null; } }); } @@ -524,23 +590,43 @@ class PlPlayerController { /// 设置倍速 Future setPlaybackSpeed(double speed) async { await _videoPlayerController?.setRate(speed); + try { + DanmakuOption currentOption = danmakuController!.option; + defaultDuration ??= currentOption.duration; + DanmakuOption updatedOption = currentOption.copyWith( + duration: (defaultDuration! / speed) * playbackSpeed); + danmakuController!.updateOption(updatedOption); + } catch (_) {} + // fix 长按倍速后放开不恢复 + if (!doubleSpeedStatus.value) { + _playbackSpeed.value = speed; + } + } + + // 还原默认速度 + Future setDefaultSpeed() async { + double speed = + videoStorage.get(VideoBoxKey.playSpeedDefault, defaultValue: 1.0); + await _videoPlayerController?.setRate(speed); _playbackSpeed.value = speed; } /// 设置倍速 - Future togglePlaybackSpeed() async { - List allowedSpeeds = - PlaySpeed.values.map((e) => e.value).toList(); - int index = allowedSpeeds.indexOf(_playbackSpeed.value); - if (index < allowedSpeeds.length - 1) { - setPlaybackSpeed(allowedSpeeds[index + 1]); - } else { - setPlaybackSpeed(allowedSpeeds[0]); - } - } + // Future togglePlaybackSpeed() async { + // List allowedSpeeds = + // PlaySpeed.values.map((e) => e.value).toList(); + // int index = allowedSpeeds.indexOf(_playbackSpeed.value); + // if (index < allowedSpeeds.length - 1) { + // setPlaybackSpeed(allowedSpeeds[index + 1]); + // } else { + // setPlaybackSpeed(allowedSpeeds[0]); + // } + // } /// 播放视频 Future play({bool repeat = false, bool hideControls = true}) async { + // 播放时自动隐藏控制条 + controls = !hideControls; // repeat为true,将从头播放 if (repeat) { await seekTo(Duration.zero); @@ -552,17 +638,18 @@ class PlPlayerController { playerStatus.status.value = PlayerStatus.playing; // screenManager.setOverlays(false); - - // 播放时自动隐藏控制条 - if (hideControls) { - _hideTaskControls(); - } + audioSessionHandler.setActive(true); } /// 暂停播放 - Future pause({bool notify = true}) async { + Future pause({bool notify = true, bool isInterrupt = false}) async { await _videoPlayerController?.pause(); playerStatus.status.value = PlayerStatus.paused; + + // 主动暂停时让出音频焦点 + if (!isInterrupt) { + audioSessionHandler.setActive(false); + } } /// 更改播放状态 @@ -671,44 +758,61 @@ class PlPlayerController { /// Toggle Change the videofit accordingly void toggleVideoFit() { - videoFitChangedTimer?.cancel(); - videoFitChanged.value = true; - // 范围内 - List attrs = videoFitType.map((e) => e['attr']).toList(); - if (attrs.indexOf(_videoFit.value) < attrs.length - 1) { - int index = attrs.indexOf(_videoFit.value); - _videoFit.value = attrs[index + 1]; - print(videoFitType[index + 1]['desc']); - SmartDialog.showToast(videoFitType[index + 1]['desc']); - } else { - // 默认 contain - _videoFit.value = videoFitType.first['attr']; - SmartDialog.showToast(videoFitType.first['desc']); - } - videoFitChangedTimer = Timer(const Duration(seconds: 1), () { - videoFitChangedTimer = null; - videoFitChanged.value = false; - }); - print(_videoFit.value); - } - - /// Change Video Fit accordingly - void onVideoFitChange(BoxFit fit) { - _videoFit.value = fit; + showDialog( + context: Get.context!, + builder: (context) { + return AlertDialog( + title: const Text('画面比例'), + content: StatefulBuilder(builder: (context, StateSetter setState) { + return Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 2, + children: [ + for (var i in videoFitType) ...[ + if (_videoFit.value == i['attr']) ...[ + FilledButton( + onPressed: () async { + _videoFit.value = i['attr']; + _videoFitDesc.value = i['desc']; + setVideoFit(); + Get.back(); + }, + child: Text(i['desc']), + ), + ] else ...[ + FilledButton.tonal( + onPressed: () async { + _videoFit.value = i['attr']; + _videoFitDesc.value = i['desc']; + setVideoFit(); + Get.back(); + }, + child: Text(i['desc']), + ), + ] + ] + ], + ); + }), + ); + }, + ); } /// 缓存fit - // Future setVideoFit() async { - // videoStorage.put(VideoBoxKey.videoBrightness, _videoFit.value.name); - // } + Future setVideoFit() async { + List attrs = videoFitType.map((e) => e['attr']).toList(); + int index = attrs.indexOf(_videoFit.value); + videoStorage.put(VideoBoxKey.cacheVideoFit, index); + } /// 读取fit - // Future getVideoFit() async { - // String fitValue = - // videoStorage.get(VideoBoxKey.videoBrightness, defaultValue: 'contain'); - // _videoFit.value = videoFitType - // .firstWhere((element) => element['attr'] == fitValue)['attr']; - // } + Future getVideoFit() async { + int fitValue = videoStorage.get(VideoBoxKey.cacheVideoFit, defaultValue: 0); + _videoFit.value = videoFitType[fitValue]['attr']; + _videoFitDesc.value = videoFitType[fitValue]['desc']; + } /// 读取亮度 // Future getVideoBrightness() async { @@ -725,17 +829,24 @@ class PlPlayerController { } } + void hiddenControls(bool val) { + showControls.value = val; + } + /// 设置长按倍速状态 live模式下禁用 void setDoubleSpeedStatus(bool val) { if (videoType.value == 'live') { return; } + if (controlsLock.value) { + return; + } _doubleSpeedStatus.value = val; - double currentSpeed = playbackSpeed; if (val) { - setPlaybackSpeed(currentSpeed * 2); + setPlaybackSpeed(longPressSpeed); } else { - setPlaybackSpeed(currentSpeed / 2); + print(playbackSpeed); + setPlaybackSpeed(playbackSpeed); } } @@ -754,7 +865,7 @@ class PlPlayerController { Future triggerFullScreen({bool status = true}) async { FullScreenMode mode = FullScreenModeCode.fromCode( setting.get(SettingBoxKey.fullScreenMode, defaultValue: 0))!; - + await StatusBarControl.setHidden(true, animation: StatusBarAnimation.FADE); if (!isFullScreen.value && status) { /// 按照视频宽高比决定全屏方向 switch (mode) { @@ -773,7 +884,7 @@ class PlPlayerController { /// 进入全屏 await enterFullScreen(); - // 横屏 + // 竖屏 await verticalScreen(); break; case FullScreenMode.horizontal: @@ -786,25 +897,39 @@ class PlPlayerController { } toggleFullScreen(true); + bool isValid = + direction.value == 'vertical' || mode == FullScreenMode.vertical + ? true + : false; var result = await showDialog( context: Get.context!, useSafeArea: false, builder: (context) => Dialog.fullscreen( backgroundColor: Colors.black, - child: PLVideoPlayer( - controller: this, - headerControl: headerControl, - danmuWidget: danmuWidget, + child: SafeArea( + // 忽略手机安全区域 + top: isValid, + left: false, + right: false, + bottom: isValid, + child: PLVideoPlayer( + controller: this, + headerControl: headerControl, + bottomControl: bottomControl, + danmuWidget: danmuWidget, + ), ), ), ); if (result == null) { // 退出全屏 + StatusBarControl.setHidden(false, animation: StatusBarAnimation.FADE); exitFullScreen(); await verticalScreen(); toggleFullScreen(false); } } else if (isFullScreen.value) { + StatusBarControl.setHidden(false, animation: StatusBarAnimation.FADE); Get.back(); exitFullScreen(); await verticalScreen(); @@ -838,10 +963,13 @@ class PlPlayerController { } // 记录播放记录 - Future makeHeartBeat(progress, {type = 'playing'}) async { + Future makeHeartBeat(int progress, {type = 'playing'}) async { if (!_enableHeart) { return false; } + if (videoType.value == 'live') { + return; + } // 播放状态变化时,更新 if (type == 'status') { await VideoHttp.heartBeat( @@ -862,6 +990,11 @@ class PlPlayerController { } } + setPlayRepeat(PlayRepeat type) { + playRepeat = type; + videoStorage.put(VideoBoxKey.playRepeat, type.value); + } + Future dispose({String type = 'single'}) async { // 每次减1,最后销毁 if (type == 'single' && playerCount.value > 1) { @@ -891,12 +1024,22 @@ class PlPlayerController { // playerStatus.status.close(); // dataStatus.status.close(); + /// 缓存本次弹幕选项 + localCache.put(LocalCacheKey.danmakuBlockType, blockTypes); + localCache.put(LocalCacheKey.danmakuShowArea, showArea); + localCache.put(LocalCacheKey.danmakuOpacity, opacityVal); + localCache.put(LocalCacheKey.danmakuFontScale, fontSizeVal); + localCache.put(LocalCacheKey.danmakuSpeed, danmakuSpeedVal); + + var pp = _videoPlayerController!.platform as NativePlayer; + await pp.setProperty('audio-files', ''); removeListeners(); await _videoPlayerController?.dispose(); _videoPlayerController = null; _instance = null; // 关闭所有视频页面恢复亮度 resetBrightness(); + videoPlayerServiceHandler.clear(); } catch (err) { print(err); } diff --git a/lib/plugin/pl_player/models/play_repeat.dart b/lib/plugin/pl_player/models/play_repeat.dart new file mode 100644 index 00000000..e68196c7 --- /dev/null +++ b/lib/plugin/pl_player/models/play_repeat.dart @@ -0,0 +1,25 @@ +enum PlayRepeat { + pause, + listOrder, + singleCycle, + listCycle, +} + +extension PlayRepeatExtension on PlayRepeat { + static final List _descList = [ + '播完暂停', + '顺序播放', + '单个循环', + '列表循环', + ]; + get description => _descList[index]; + + static final List _valueList = [ + 1, + 2, + 3, + 4, + ]; + get value => _valueList[index]; + get defaultValue => _valueList[1]; +} diff --git a/lib/plugin/pl_player/models/play_speed.dart b/lib/plugin/pl_player/models/play_speed.dart index 5ccb91a4..533e0254 100644 --- a/lib/plugin/pl_player/models/play_speed.dart +++ b/lib/plugin/pl_player/models/play_speed.dart @@ -9,35 +9,18 @@ enum PlaySpeed { onePointSevenFive, two, - twoPointTwoFive, - twoPointFive, - twoPointSevenFive, - - twhree, - threePointTwoFive, - threePointFive, - threePointSevenFive, - - four, } extension PlaySpeedExtension on PlaySpeed { static final List _descList = [ - '0.25倍', - '0.5倍', - '0.75倍', - '正常速度', - '1.25倍', - '1.5倍', - '2.0倍', - '2.25倍', - '2.5倍', - '2.75倍', - '3.0倍', - '3.25倍', - '3.5倍', - '3.75倍', - '4.0倍' + '0.25', + '0.5', + '0.75', + '正常', + '1.25', + '1.5', + '1.75', + '2.0', ]; get description => _descList[index]; @@ -50,14 +33,6 @@ extension PlaySpeedExtension on PlaySpeed { 1.5, 1.75, 2.0, - 2.25, - 2.5, - 2.75, - 3.0, - 3.25, - 3.5, - 3.75, - 4.0, ]; get value => _valueList[index]; get defaultValue => _valueList[3]; diff --git a/lib/plugin/pl_player/utils/fullscreen.dart b/lib/plugin/pl_player/utils/fullscreen.dart index 6d36c409..06ae2d60 100644 --- a/lib/plugin/pl_player/utils/fullscreen.dart +++ b/lib/plugin/pl_player/utils/fullscreen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:auto_orientation/auto_orientation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -11,16 +12,17 @@ Future landScape() async { if (kIsWeb) { await document.documentElement?.requestFullscreen(); } else if (Platform.isAndroid || Platform.isIOS) { - await SystemChrome.setEnabledSystemUIMode( - SystemUiMode.immersiveSticky, - overlays: [], - ); - await SystemChrome.setPreferredOrientations( - [ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ], - ); + // await SystemChrome.setEnabledSystemUIMode( + // SystemUiMode.immersiveSticky, + // overlays: [], + // ); + // await SystemChrome.setPreferredOrientations( + // [ + // DeviceOrientation.landscapeLeft, + // DeviceOrientation.landscapeRight, + // ], + // ); + await AutoOrientation.landscapeAutoMode(forceSensor: true); } else if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { await const MethodChannel('com.alexmercerind/media_kit_video') .invokeMethod( diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 1b3703d4..8b8b5780 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -29,11 +29,13 @@ import 'widgets/forward_seek.dart'; class PLVideoPlayer extends StatefulWidget { final PlPlayerController controller; final PreferredSizeWidget? headerControl; + final PreferredSizeWidget? bottomControl; final Widget? danmuWidget; const PLVideoPlayer({ required this.controller, this.headerControl, + this.bottomControl, this.danmuWidget, super.key, }); @@ -46,48 +48,47 @@ class _PLVideoPlayerState extends State with TickerProviderStateMixin { late AnimationController animationController; late VideoController videoController; + final PLVideoPlayerController _ctr = Get.put(PLVideoPlayerController()); - bool _mountSeekBackwardButton = false; - bool _mountSeekForwardButton = false; - bool _hideSeekBackwardButton = false; - bool _hideSeekForwardButton = false; + // bool _mountSeekBackwardButton = false; + // bool _mountSeekForwardButton = false; + // bool _hideSeekBackwardButton = false; + // bool _hideSeekForwardButton = false; - double _brightnessValue = 0.0; - bool _brightnessIndicator = false; + // double _brightnessValue = 0.0; + // bool _brightnessIndicator = false; Timer? _brightnessTimer; - double _volumeValue = 0.0; - bool _volumeIndicator = false; + // double _volumeValue = 0.0; + // bool _volumeIndicator = false; Timer? _volumeTimer; double _distance = 0.0; // 初始手指落下位置 double _initTapPositoin = 0.0; - bool _volumeInterceptEventStream = false; + // bool _volumeInterceptEventStream = false; Box setting = GStrorage.setting; late FullScreenMode mode; late int defaultBtmProgressBehavior; late bool enableQuickDouble; + late bool enableBackgroundPlay; + late double screenWidth; void onDoubleTapSeekBackward() { - setState(() { - _mountSeekBackwardButton = true; - }); + _ctr.onDoubleTapSeekBackward(); } void onDoubleTapSeekForward() { - setState(() { - _mountSeekForwardButton = true; - }); + _ctr.onDoubleTapSeekForward(); } // 双击播放、暂停 void onDoubleTapCenter() { final _ = widget.controller; - if (_.playerStatus.status.value == PlayerStatus.playing) { - _.togglePlay(); + if (_.videoPlayerController!.state.playing) { + _.pause(); } else { _.play(); } @@ -116,25 +117,26 @@ class _PLVideoPlayerState extends State @override void initState() { super.initState(); + screenWidth = Get.size.width; animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300)); videoController = widget.controller.videoController!; widget.controller.headerControl = widget.headerControl; + widget.controller.bottomControl = widget.bottomControl; widget.controller.danmuWidget = widget.danmuWidget; defaultBtmProgressBehavior = setting.get(SettingBoxKey.btmProgressBehavior, defaultValue: BtmProgresBehavior.values.first.code); enableQuickDouble = setting.get(SettingBoxKey.enableQuickDouble, defaultValue: true); - + enableBackgroundPlay = + setting.get(SettingBoxKey.enableBackgroundPlay, defaultValue: false); Future.microtask(() async { try { FlutterVolumeController.showSystemUI = true; - _volumeValue = (await FlutterVolumeController.getVolume())!; + _ctr.volumeValue.value = (await FlutterVolumeController.getVolume())!; FlutterVolumeController.addListener((value) { - if (mounted && !_volumeInterceptEventStream) { - setState(() { - _volumeValue = value; - }); + if (mounted && !_ctr.volumeInterceptEventStream.value) { + _ctr.volumeValue.value = value; } }); } catch (_) {} @@ -142,12 +144,10 @@ class _PLVideoPlayerState extends State Future.microtask(() async { try { - _brightnessValue = await ScreenBrightness().current; + _ctr.brightnessValue.value = await ScreenBrightness().current; ScreenBrightness().onCurrentBrightnessChanged.listen((value) { if (mounted) { - setState(() { - _brightnessValue = value; - }); + _ctr.brightnessValue.value = value; } }); } catch (_) {} @@ -159,18 +159,14 @@ class _PLVideoPlayerState extends State FlutterVolumeController.showSystemUI = false; await FlutterVolumeController.setVolume(value); } catch (_) {} - setState(() { - _volumeValue = value; - _volumeIndicator = true; - _volumeInterceptEventStream = true; - }); + _ctr.volumeValue.value = value; + _ctr.volumeIndicator.value = true; + _ctr.volumeInterceptEventStream.value = true; _volumeTimer?.cancel(); _volumeTimer = Timer(const Duration(milliseconds: 200), () { if (mounted) { - setState(() { - _volumeIndicator = false; - _volumeInterceptEventStream = false; - }); + _ctr.volumeIndicator.value = false; + _ctr.volumeInterceptEventStream.value = false; } }); } @@ -179,15 +175,11 @@ class _PLVideoPlayerState extends State try { await ScreenBrightness().setScreenBrightness(value); } catch (_) {} - setState(() { - _brightnessIndicator = true; - }); + _ctr.brightnessIndicator.value = true; _brightnessTimer?.cancel(); _brightnessTimer = Timer(const Duration(milliseconds: 200), () { if (mounted) { - setState(() { - _brightnessIndicator = false; - }); + _ctr.brightnessIndicator.value = false; } }); widget.controller.brightness.value = value; @@ -225,6 +217,8 @@ class _PLVideoPlayerState extends State () => Video( controller: videoController, controls: NoVideoControls, + pauseUponEnteringBackgroundMode: !enableBackgroundPlay, + resumeUponEnteringForegroundMode: true, subtitleViewConfiguration: SubtitleViewConfiguration( style: subTitleStyle, textAlign: TextAlign.center, @@ -239,35 +233,25 @@ class _PLVideoPlayerState extends State () => Align( alignment: Alignment.topCenter, child: FractionalTranslation( - translation: const Offset(0.0, 1), // 上下偏移量(负数向上偏移) + translation: const Offset(0.0, 0.3), // 上下偏移量(负数向上偏移) child: AnimatedOpacity( curve: Curves.easeInOut, opacity: _.doubleSpeedStatus.value ? 1.0 : 0.0, duration: const Duration(milliseconds: 150), child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0x88000000), - borderRadius: BorderRadius.circular(64.0), - ), - height: 34.0, - width: 86.0, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 3), - Image.asset( - 'assets/images/run-pokemon.gif', - height: 20, - ), - const Text( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(16.0), + ), + height: 32.0, + width: 70.0, + child: const Center( + child: Text( '倍速中', - style: TextStyle(color: Colors.white, fontSize: 12), + style: TextStyle(color: Colors.white, fontSize: 13), ), - const SizedBox(width: 4), - ], - ), - ), + )), ), ), ), @@ -325,125 +309,129 @@ class _PLVideoPlayerState extends State ), /// 音量🔊 控制条展示 - Align( - alignment: Alignment.center, - child: AnimatedOpacity( - curve: Curves.easeInOut, - opacity: _volumeIndicator ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0x88000000), - borderRadius: BorderRadius.circular(64.0), - ), - height: 34.0, - width: 70.0, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - height: 34.0, - width: 28.0, - alignment: Alignment.centerRight, - child: Icon( - _volumeValue == 0.0 - ? Icons.volume_off - : _volumeValue < 0.5 - ? Icons.volume_down - : Icons.volume_up, - color: const Color(0xFFFFFFFF), - size: 20.0, - ), - ), - Expanded( - child: Text( - '${(_volumeValue * 100.0).round()}%', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 13.0, - color: Color(0xFFFFFFFF), + Obx( + () => Align( + alignment: Alignment.center, + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: _ctr.volumeIndicator.value ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 34.0, + width: 70.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 34.0, + width: 28.0, + alignment: Alignment.centerRight, + child: Icon( + _ctr.volumeValue.value == 0.0 + ? Icons.volume_off + : _ctr.volumeValue.value < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: const Color(0xFFFFFFFF), + size: 20.0, ), ), - ), - const SizedBox(width: 6.0), - ], + Expanded( + child: Text( + '${(_ctr.volumeValue.value * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 6.0), + ], + ), ), ), ), ), /// 亮度🌞 控制条展示 - Align( - alignment: Alignment.center, - child: AnimatedOpacity( - curve: Curves.easeInOut, - opacity: _brightnessIndicator ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0x88000000), - borderRadius: BorderRadius.circular(64.0), - ), - height: 34.0, - width: 70.0, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - height: 30.0, - width: 28.0, - alignment: Alignment.centerRight, - child: Icon( - _brightnessValue < 1.0 / 3.0 - ? Icons.brightness_low - : _brightnessValue < 2.0 / 3.0 - ? Icons.brightness_medium - : Icons.brightness_high, - color: const Color(0xFFFFFFFF), - size: 18.0, - ), - ), - const SizedBox(width: 2.0), - Expanded( - child: Text( - '${(_brightnessValue * 100.0).round()}%', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 13.0, - color: Color(0xFFFFFFFF), + Obx( + () => Align( + alignment: Alignment.center, + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: _ctr.brightnessIndicator.value ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 34.0, + width: 70.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 30.0, + width: 28.0, + alignment: Alignment.centerRight, + child: Icon( + _ctr.brightnessValue.value < 1.0 / 3.0 + ? Icons.brightness_low + : _ctr.brightnessValue.value < 2.0 / 3.0 + ? Icons.brightness_medium + : Icons.brightness_high, + color: const Color(0xFFFFFFFF), + size: 18.0, ), ), - ), - const SizedBox(width: 6.0), - ], + const SizedBox(width: 2.0), + Expanded( + child: Text( + '${(_ctr.brightnessValue.value * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 6.0), + ], + ), ), ), ), ), - Obx(() { - if (_.buffered.value == Duration.zero) { - return Positioned.fill( - child: Container( - color: Colors.black, - child: Center( - child: Image.asset( - 'assets/images/loading.gif', - height: 25, - ), - ), - ), - ); - } else { - return Container(); - } - }), + // Obx(() { + // if (_.buffered.value == Duration.zero) { + // return Positioned.fill( + // child: Container( + // color: Colors.black, + // child: Center( + // child: Image.asset( + // 'assets/images/loading.gif', + // height: 25, + // ), + // ), + // ), + // ); + // } else { + // return Container(); + // } + // }), /// 弹幕面板 if (widget.danmuWidget != null) @@ -492,19 +480,13 @@ class _PLVideoPlayerState extends State return; } final tapPosition = details.localPosition.dx; - int curSliderPosition = _.sliderPosition.value.inSeconds; - late int result; - if (tapPosition - _initTapPositoin > 0) { - // 快进 - /// TODO 优化屏幕越小效果越明显 - result = (curSliderPosition + 1) - .clamp(0, _.duration.value.inSeconds); - } else { - // 快退 - result = (curSliderPosition - 1) - .clamp(0, _.duration.value.inSeconds); - } - _.onUodatedSliderProgress(Duration(seconds: result)); + int curSliderPosition = _.sliderPosition.value.inMilliseconds; + double scale = 90000 / MediaQuery.of(context).size.width; + Duration pos = Duration( + milliseconds: + curSliderPosition + (details.delta.dx * scale).round()); + Duration result = pos.clamp(Duration.zero, _.duration.value); + _.onUodatedSliderProgress(result); _.onChangedSliderStart(); _initTapPositoin = tapPosition; }, @@ -528,7 +510,11 @@ class _PLVideoPlayerState extends State } if (tapPosition < sectionWidth) { // 左边区域 👈 - final brightness = _brightnessValue - delta / 100.0; + double level = (_.isFullScreen.value + ? Get.size.height + : screenWidth * 9 / 16) * + 3; + final brightness = _ctr.brightnessValue.value - delta / level; final result = brightness.clamp(0.0, 1.0); setBrightness(result); } else if (tapPosition < sectionWidth * 2) { @@ -551,7 +537,11 @@ class _PLVideoPlayerState extends State _distance = dy; } else { // 右边区域 👈 - final volume = _volumeValue - delta / 100.0; + double level = (_.isFullScreen.value + ? Get.size.height + : screenWidth * 9 / 16) * + 3; + final volume = _ctr.volumeValue.value - delta / level; final result = volume.clamp(0.0, 1.0); setVolume(result); } @@ -561,19 +551,20 @@ class _PLVideoPlayerState extends State ), // 头部、底部控制条 - Obx( - () => Visibility( - visible: _.videoType.value != 'live', - child: Column( + SafeArea( + top: false, + bottom: false, + child: Obx( + () => Column( children: [ - if (widget.headerControl != null) + if (widget.headerControl != null || _.headerControl != null) ClipRect( clipBehavior: Clip.hardEdge, child: AppBarAni( controller: animationController, visible: !_.controlsLock.value && _.showControls.value, position: 'top', - child: widget.headerControl!, + child: widget.headerControl ?? _.headerControl!, ), ), const Spacer(), @@ -583,9 +574,11 @@ class _PLVideoPlayerState extends State controller: animationController, visible: !_.controlsLock.value && _.showControls.value, position: 'bottom', - child: BottomControl( - controller: widget.controller, - triggerFullScreen: widget.controller.triggerFullScreen), + child: widget.bottomControl ?? + BottomControl( + controller: widget.controller, + triggerFullScreen: + widget.controller.triggerFullScreen), ), ), ], @@ -608,6 +601,10 @@ class _PLVideoPlayerState extends State !_.isFullScreen.value) { return Container(); } + + if (_.videoType.value == 'live') { + return Container(); + } if (value > max || max <= 0) { return Container(); } @@ -704,103 +701,129 @@ class _PLVideoPlayerState extends State }), /// 点击 快进/快退 - if (_mountSeekBackwardButton || _mountSeekForwardButton) - Positioned.fill( - child: Row( - children: [ - Expanded( - child: _mountSeekBackwardButton - ? TweenAnimationBuilder( - tween: Tween( - begin: 0.0, - end: _hideSeekBackwardButton ? 0.0 : 1.0, - ), - duration: const Duration(milliseconds: 500), - builder: (context, value, child) => Opacity( - opacity: value, - child: child, - ), - onEnd: () { - if (_hideSeekBackwardButton) { - setState(() { - _hideSeekBackwardButton = false; - _mountSeekBackwardButton = false; - }); - } - }, - child: BackwardSeekIndicator( - onChanged: (value) { - // _seekBarDeltaValueNotifier.value = -value; + Obx( + () => Visibility( + visible: _ctr.mountSeekBackwardButton.value || + _ctr.mountSeekForwardButton.value, + child: Positioned.fill( + child: Row( + children: [ + Expanded( + child: _ctr.mountSeekBackwardButton.value + ? TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: + _ctr.hideSeekBackwardButton.value ? 0.0 : 1.0, + ), + duration: const Duration(milliseconds: 500), + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + onEnd: () { + if (_ctr.hideSeekBackwardButton.value) { + _ctr.hideSeekBackwardButton.value = false; + _ctr.mountSeekBackwardButton.value = false; + } }, - onSubmitted: (value) { - setState(() { - _hideSeekBackwardButton = true; - }); - Player player = - widget.controller.videoPlayerController!; - var result = player.state.position - value; - result = result.clamp( - Duration.zero, - player.state.duration, - ); - player.seek(result); - widget.controller.play(); - }, - ), - ) - : const SizedBox(), - ), - Expanded( - child: SizedBox( - width: MediaQuery.of(context).size.width / 4, + child: BackwardSeekIndicator( + onChanged: (value) { + // _seekBarDeltaValueNotifier.value = -value; + }, + onSubmitted: (value) { + _ctr.hideSeekBackwardButton.value = true; + Player player = + widget.controller.videoPlayerController!; + var result = player.state.position - value; + result = result.clamp( + Duration.zero, + player.state.duration, + ); + player.seek(result); + widget.controller.play(); + }, + ), + ) + : const SizedBox(), ), - ), - Expanded( - child: _mountSeekForwardButton - ? TweenAnimationBuilder( - tween: Tween( - begin: 0.0, - end: _hideSeekForwardButton ? 0.0 : 1.0, - ), - duration: const Duration(milliseconds: 500), - builder: (context, value, child) => Opacity( - opacity: value, - child: child, - ), - onEnd: () { - if (_hideSeekForwardButton) { - setState(() { - _hideSeekForwardButton = false; - _mountSeekForwardButton = false; - }); - } - }, - child: ForwardSeekIndicator( - onChanged: (value) { - // _seekBarDeltaValueNotifier.value = value; + Expanded( + child: SizedBox( + width: MediaQuery.of(context).size.width / 4, + ), + ), + Expanded( + child: _ctr.mountSeekForwardButton.value + ? TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: _ctr.hideSeekForwardButton.value ? 0.0 : 1.0, + ), + duration: const Duration(milliseconds: 500), + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + onEnd: () { + if (_ctr.hideSeekForwardButton.value) { + _ctr.hideSeekForwardButton.value = false; + _ctr.mountSeekForwardButton.value = false; + } }, - onSubmitted: (value) { - setState(() { - _hideSeekForwardButton = true; - }); - Player player = - widget.controller.videoPlayerController!; - var result = player.state.position + value; - result = result.clamp( - Duration.zero, - player.state.duration, - ); - player.seek(result); - widget.controller.play(); - }, - ), - ) - : const SizedBox(), - ), - ], + child: ForwardSeekIndicator( + onChanged: (value) { + // _seekBarDeltaValueNotifier.value = value; + }, + onSubmitted: (value) { + _ctr.hideSeekForwardButton.value = true; + Player player = + widget.controller.videoPlayerController!; + var result = player.state.position + value; + result = result.clamp( + Duration.zero, + player.state.duration, + ); + player.seek(result); + widget.controller.play(); + }, + ), + ) + : const SizedBox(), + ), + ], + ), ), ), + ), ], ); } } + +class PLVideoPlayerController extends GetxController { + RxBool mountSeekBackwardButton = false.obs; + RxBool mountSeekForwardButton = false.obs; + RxBool hideSeekBackwardButton = false.obs; + RxBool hideSeekForwardButton = false.obs; + + RxDouble brightnessValue = 0.0.obs; + RxBool brightnessIndicator = false.obs; + + RxDouble volumeValue = 0.0.obs; + RxBool volumeIndicator = false.obs; + + RxDouble distance = 0.0.obs; + // 初始手指落下位置 + RxDouble initTapPositoin = 0.0.obs; + + RxBool volumeInterceptEventStream = false.obs; + + // 双击快进 展示样式 + void onDoubleTapSeekForward() { + mountSeekForwardButton.value = true; + } + + void onDoubleTapSeekBackward() { + mountSeekBackwardButton.value = true; + } +} diff --git a/lib/plugin/pl_player/widgets/bottom_control.dart b/lib/plugin/pl_player/widgets/bottom_control.dart index fb0f42b6..de8263e5 100644 --- a/lib/plugin/pl_player/widgets/bottom_control.dart +++ b/lib/plugin/pl_player/widgets/bottom_control.dart @@ -115,15 +115,22 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { // ), // ), // ), - ComBtn( - icon: const Icon( - Icons.settings_overscan_outlined, - size: 18, - color: Colors.white, + SizedBox( + height: 30, + child: TextButton( + onPressed: () => _.toggleVideoFit(), + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + child: Obx( + () => Text( + _.videoFitDEsc.value, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), ), - fuc: () => _.toggleVideoFit(), ), - const SizedBox(width: 4), + const SizedBox(width: 10), // 全屏 Obx( () => ComBtn( @@ -139,7 +146,7 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 12), ], ), ); diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index d6371288..c184c220 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -11,13 +11,18 @@ import 'package:pilipala/pages/dynamics/index.dart'; import 'package:pilipala/pages/fan/index.dart'; import 'package:pilipala/pages/fav/index.dart'; import 'package:pilipala/pages/favDetail/index.dart'; +import 'package:pilipala/pages/fav_search/index.dart'; import 'package:pilipala/pages/follow/index.dart'; import 'package:pilipala/pages/history/index.dart'; +import 'package:pilipala/pages/history_search/index.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/hot/index.dart'; +import 'package:pilipala/pages/html/index.dart'; import 'package:pilipala/pages/later/index.dart'; import 'package:pilipala/pages/liveRoom/view.dart'; +import 'package:pilipala/pages/login/index.dart'; import 'package:pilipala/pages/member/index.dart'; +import 'package:pilipala/pages/member_search/index.dart'; import 'package:pilipala/pages/preview/index.dart'; import 'package:pilipala/pages/search/index.dart'; import 'package:pilipala/pages/searchResult/index.dart'; @@ -25,6 +30,7 @@ import 'package:pilipala/pages/setting/extra_setting.dart'; import 'package:pilipala/pages/setting/pages/color_select.dart'; import 'package:pilipala/pages/setting/pages/display_mode.dart'; import 'package:pilipala/pages/setting/pages/font_size_select.dart'; +import 'package:pilipala/pages/setting/pages/play_speed_set.dart'; import 'package:pilipala/pages/setting/play_setting.dart'; import 'package:pilipala/pages/setting/privacy_setting.dart'; import 'package:pilipala/pages/setting/style_setting.dart'; @@ -48,13 +54,13 @@ class Routes { // 视频详情 CustomGetPage(name: '/video', page: () => const VideoDetailPage()), // 图片预览 - GetPage( - name: '/preview', - page: () => const ImagePreview(), - transition: Transition.fade, - transitionDuration: const Duration(milliseconds: 300), - showCupertinoParallax: false, - ), + // GetPage( + // name: '/preview', + // page: () => const ImagePreview(), + // transition: Transition.fade, + // transitionDuration: const Duration(milliseconds: 300), + // showCupertinoParallax: false, + // ), // CustomGetPage(name: '/webview', page: () => const WebviewPage()), // 设置 @@ -86,6 +92,7 @@ class Routes { CustomGetPage(name: '/liveRoom', page: () => const LiveRoomPage()), // 用户中心 CustomGetPage(name: '/member', page: () => const MemberPage()), + CustomGetPage(name: '/memberSearch', page: () => const MemberSearchPage()), // 二级回复 CustomGetPage( name: '/replyReply', page: () => const VideoReplyReplyPanel()), @@ -108,8 +115,22 @@ class Routes { name: '/displayModeSetting', page: () => const SetDiaplayMode()), // 关于 CustomGetPage(name: '/about', page: () => const AboutPage()), +<<<<<<< HEAD // 音频播放 CustomGetPage(name: '/audioPlayer', page: () => const AudioPlayerPage()), +======= + // + CustomGetPage(name: '/htmlRender', page: () => const HtmlRenderPage()), + // 历史记录搜索 + CustomGetPage( + name: '/historySearch', page: () => const HistorySearchPage()), + + CustomGetPage(name: '/playSpeedSet', page: () => const PlaySpeedPage()), + // 收藏搜索 + CustomGetPage(name: '/favSearch', page: () => const FavSearchPage()), + // 登录页面 + CustomGetPage(name: '/loginPage', page: () => const LoginPage()), +>>>>>>> main ]; } diff --git a/lib/services/audio_handler.dart b/lib/services/audio_handler.dart new file mode 100644 index 00000000..61b32b96 --- /dev/null +++ b/lib/services/audio_handler.dart @@ -0,0 +1,180 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/models/bangumi/info.dart'; +import 'package:pilipala/models/video_detail_res.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/utils/storage.dart'; + +Future initAudioService() async { + return await AudioService.init( + builder: () => VideoPlayerServiceHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.guozhigq.pilipala.audio', + androidNotificationChannelName: 'Audio Service Pilipala', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + fastForwardInterval: Duration(seconds: 10), + rewindInterval: Duration(seconds: 10), + androidNotificationChannelDescription: 'Media notification channel', + androidNotificationIcon: 'drawable/ic_notification_icon', + ), + ); +} + +class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { + static final List _item = []; + Box setting = GStrorage.setting; + bool enableBackgroundPlay = false; + + VideoPlayerServiceHandler() { + revalidateSetting(); + } + + revalidateSetting() { + enableBackgroundPlay = + setting.get(SettingBoxKey.enableBackgroundPlay, defaultValue: false); + } + + @override + Future play() async { + PlPlayerController.getInstance().play(); + } + + @override + Future pause() async { + PlPlayerController.getInstance().pause(); + } + + @override + Future seek(Duration position) async { + playbackState.add(playbackState.value.copyWith( + updatePosition: position, + )); + await PlPlayerController.getInstance().seekTo(position); + } + + Future setMediaItem(MediaItem newMediaItem) async { + if (!enableBackgroundPlay) return; + mediaItem.add(newMediaItem); + } + + Future setPlaybackState(PlayerStatus status, bool isBuffering) async { + if (!enableBackgroundPlay) return; + + final AudioProcessingState processingState; + final playing = status == PlayerStatus.playing; + if (status == PlayerStatus.completed) { + processingState = AudioProcessingState.completed; + } else if (isBuffering) { + processingState = AudioProcessingState.buffering; + } else { + processingState = AudioProcessingState.ready; + } + + playbackState.add(playbackState.value.copyWith( + processingState: + isBuffering ? AudioProcessingState.buffering : processingState, + controls: [ + MediaControl.rewind + .copyWith(androidIcon: 'drawable/ic_baseline_replay_10_24'), + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.fastForward + .copyWith(androidIcon: 'drawable/ic_baseline_forward_10_24'), + ], + playing: playing, + systemActions: const { + MediaAction.seek, + }, + )); + } + + onStatusChange(PlayerStatus status, bool isBuffering) { + if (!enableBackgroundPlay) return; + + if (_item.isEmpty) return; + setPlaybackState(status, isBuffering); + } + + onVideoDetailChange(dynamic data, int cid) { + if (!enableBackgroundPlay) return; + + if (data == null) return; + Map argMap = Get.arguments; + final heroTag = argMap['heroTag']; + + late MediaItem? mediaItem; + if (data is VideoDetailData) { + if ((data.pages?.length ?? 0) > 1) { + final current = data.pages?.firstWhere((element) => element.cid == cid); + mediaItem = MediaItem( + id: heroTag, + title: current?.pagePart ?? "", + artist: data.title ?? "", + album: data.title ?? "", + duration: Duration(seconds: current?.duration ?? 0), + artUri: Uri.parse(data.pic ?? ""), + ); + } else { + mediaItem = MediaItem( + id: heroTag, + title: data.title ?? "", + artist: data.owner?.name ?? "", + duration: Duration(seconds: data.duration ?? 0), + artUri: Uri.parse(data.pic ?? ""), + ); + } + } else if (data is BangumiInfoModel) { + final current = + data.episodes?.firstWhere((element) => element.cid == cid); + mediaItem = MediaItem( + id: heroTag, + title: current?.longTitle ?? "", + artist: data.title ?? "", + duration: Duration(milliseconds: current?.duration ?? 0), + artUri: Uri.parse(data.cover ?? ""), + ); + } + if (mediaItem == null) return; + setMediaItem(mediaItem); + _item.add(mediaItem); + } + + onVideoDetailDispose() { + if (!enableBackgroundPlay) return; + + playbackState.add(playbackState.value.copyWith( + processingState: AudioProcessingState.idle, + playing: false, + )); + _item.removeLast(); + if (_item.isNotEmpty) { + setMediaItem(_item.last); + } + if (_item.isEmpty) { + playbackState + .add(playbackState.value.copyWith(updatePosition: Duration.zero)); + } + stop(); + } + + clear() { + if (!enableBackgroundPlay) return; + + mediaItem.add(null); + playbackState.add(PlaybackState( + processingState: AudioProcessingState.idle, + playing: false, + )); + _item.clear(); + stop(); + } + + onPositionChange(Duration position) { + if (!enableBackgroundPlay) return; + + playbackState.add(playbackState.value.copyWith( + updatePosition: position, + )); + } +} diff --git a/lib/services/audio_session.dart b/lib/services/audio_session.dart new file mode 100644 index 00000000..98707652 --- /dev/null +++ b/lib/services/audio_session.dart @@ -0,0 +1,53 @@ +import 'package:audio_session/audio_session.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; + +class AudioSessionHandler { + late AudioSession session; + bool _playInterrupted = false; + + setActive(bool active) { + session.setActive(active); + } + + AudioSessionHandler() { + initSession(); + } + + Future initSession() async { + session = await AudioSession.instance; + session.configure(const AudioSessionConfiguration.music()); + + session.interruptionEventStream.listen((event) { + final player = PlPlayerController.getInstance(); + if (event.begin) { + switch (event.type) { + case AudioInterruptionType.duck: + player.setVolume(player.volume.value * 0.5); + break; + case AudioInterruptionType.pause: + case AudioInterruptionType.unknown: + player.pause(isInterrupt: true); + _playInterrupted = true; + break; + } + } else { + switch (event.type) { + case AudioInterruptionType.duck: + player.setVolume(player.volume.value * 2); + break; + case AudioInterruptionType.pause: + if (_playInterrupted) PlPlayerController.getInstance().play(); + break; + case AudioInterruptionType.unknown: + break; + } + _playInterrupted = false; + } + }); + + // 耳机拔出暂停 + session.becomingNoisyEventStream.listen((_) { + PlPlayerController.getInstance().pause(); + }); + } +} diff --git a/lib/services/service_locator.dart b/lib/services/service_locator.dart new file mode 100644 index 00000000..e4497660 --- /dev/null +++ b/lib/services/service_locator.dart @@ -0,0 +1,11 @@ +import 'audio_handler.dart'; +import 'audio_session.dart'; + +late VideoPlayerServiceHandler videoPlayerServiceHandler; +late AudioSessionHandler audioSessionHandler; + +Future setupServiceLocator() async { + final audio = await initAudioService(); + videoPlayerServiceHandler = audio; + audioSessionHandler = AudioSessionHandler(); +} diff --git a/lib/utils/danmaku.dart b/lib/utils/danmaku.dart index a76cc77f..8305edec 100644 --- a/lib/utils/danmaku.dart +++ b/lib/utils/danmaku.dart @@ -3,6 +3,7 @@ import 'package:ns_danmaku/ns_danmaku.dart'; class DmUtils { static Color decimalToColor(int decimalColor) { + // 16777215 表示白色 int red = (decimalColor >> 16) & 0xFF; int green = (decimalColor >> 8) & 0xFF; int blue = decimalColor & 0xFF; diff --git a/lib/utils/download.dart b/lib/utils/download.dart index ae89ef04..ad008f6d 100644 --- a/lib/utils/download.dart +++ b/lib/utils/download.dart @@ -2,8 +2,8 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:saver_gallery/saver_gallery.dart'; class DownloadUtils { // 获取存储权限 @@ -15,25 +15,32 @@ class DownloadUtils { statuses[Permission.storage].toString(); } - static Future downloadImg(String imgUrl) async { - await requestStoragePer(); - SmartDialog.showLoading(msg: '保存中'); - var response = await Dio() - .get(imgUrl, options: Options(responseType: ResponseType.bytes)); - String picName = - "plpl_cover_${DateTime.now().toString().split('-').join()}.png"; - final result = await ImageGallerySaver.saveImage( - Uint8List.fromList(response.data), - quality: 100, - name: picName, - ); - SmartDialog.dismiss(); - if (result != null) { - if (result['isSuccess']) { - // ignore: avoid_print + static Future downloadImg(String imgUrl, + {String imgType = 'cover'}) async { + try { + await requestStoragePer(); + SmartDialog.showLoading(msg: '保存中'); + var response = await Dio() + .get(imgUrl, options: Options(responseType: ResponseType.bytes)); + String picName = + "plpl_${imgType}_${DateTime.now().toString().split('-').join()}"; + final SaveResult result = await SaverGallery.saveImage( + Uint8List.fromList(response.data), + quality: 60, + name: picName, + // 保存到 PiliPala文件夹 + androidRelativePath: "Pictures/PiliPala", + androidExistNotSave: false, + ); + SmartDialog.dismiss(); + if (result.isSuccess) { await SmartDialog.showToast('「$picName」已保存 '); } + return true; + } catch (err) { + SmartDialog.dismiss(); + SmartDialog.showToast(err.toString()); + return true; } - return true; } } diff --git a/lib/utils/em.dart b/lib/utils/em.dart index 68eed977..8dba2c13 100644 --- a/lib/utils/em.dart +++ b/lib/utils/em.dart @@ -19,6 +19,10 @@ class Em { return regCate(matchStr); }, onNonMatch: (String str) { if (str != '') { + str = str + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); Map map = {'type': 'text', 'text': str}; res.add(map); } diff --git a/lib/utils/login.dart b/lib/utils/login.dart index 54c03775..59c53027 100644 --- a/lib/utils/login.dart +++ b/lib/utils/login.dart @@ -1,9 +1,14 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/pages/dynamics/index.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/media/index.dart'; import 'package:pilipala/pages/mine/index.dart'; +import 'package:uuid/uuid.dart'; class LoginUtils { static Future refreshLoginStatus(bool status) async { @@ -27,4 +32,29 @@ class LoginUtils { SmartDialog.showToast('refreshLoginStatus error: ${err.toString()}'); } } + + static String buvid() { + var mac = []; + var random = Random(); + + for (var i = 0; i < 6; i++) { + var min = 0; + var max = 0xff; + var num = (random.nextInt(max - min + 1) + min).toRadixString(16); + mac.add(num); + } + + var md5Str = md5.convert(utf8.encode(mac.join(':'))).toString(); + var md5Arr = md5Str.split(''); + return 'XY${md5Arr[2]}${md5Arr[12]}${md5Arr[22]}$md5Str'; + } + + static String getUUID() { + return const Uuid().v4().replaceAll('-', ''); + } + + static String generateBuvid() { + String uuid = getUUID() + getUUID(); + return 'XY${uuid.substring(0, 35).toUpperCase()}'; + } } diff --git a/lib/utils/proxy.dart b/lib/utils/proxy.dart new file mode 100644 index 00000000..cba74c42 --- /dev/null +++ b/lib/utils/proxy.dart @@ -0,0 +1,28 @@ +import 'dart:io'; +import 'package:system_proxy/system_proxy.dart'; + +class CustomProxy { + init() async { + Map? proxy = await SystemProxy.getProxySettings(); + if (proxy != null) { + HttpOverrides.global = + ProxiedHttpOverrides(proxy['host']!, proxy['port']!); + } + } +} + +class ProxiedHttpOverrides extends HttpOverrides { + final String _port; + final String _host; + + ProxiedHttpOverrides(this._host, this._port); + + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + // set proxy + ..findProxy = (uri) { + return "PROXY $_host:$_port;"; + }; + } +} diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index f73b1ba7..1d20877b 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -34,7 +34,12 @@ class GStrorage { }, ); // 本地缓存 - localCache = await Hive.openBox('localCache'); + localCache = await Hive.openBox( + 'localCache', + compactionStrategy: (entries, deletedEntries) { + return deletedEntries > 4; + }, + ); // 设置 setting = await Hive.openBox('setting'); // 搜索历史 @@ -99,9 +104,14 @@ class SettingBoxKey { static const String enableAutoBrightness = 'enableAutoBrightness'; static const String enableAutoEnter = 'enableAutoEnter'; static const String enableAutoExit = 'enableAutoExit'; + static const String p1080 = 'p1080'; + static const String enableCDN = 'enableCDN'; + static const String autoPiP = 'autoPiP'; + // youtube 双击快进快退 static const String enableQuickDouble = 'enableQuickDouble'; static const String enableShowDanmaku = 'enableShowDanmaku'; + static const String enableBackgroundPlay = 'enableBackgroundPlay'; /// 隐私 static const String blackMidsList = 'blackMidsList'; @@ -113,6 +123,10 @@ class SettingBoxKey { static const String enableHotKey = 'enableHotKey'; static const String enableQuickFav = 'enableQuickFav'; static const String enableWordRe = 'enableWordRe'; + static const String enableSearchWord = 'enableSearchWord'; + static const String enableRcmdDynamic = 'enableRcmdDynamic'; + static const String enableSaveLastData = 'enableSaveLastData'; + static const String enableSystemProxy = 'enableSystemProxy'; /// 外观 static const String themeMode = 'themeMode'; @@ -121,6 +135,9 @@ class SettingBoxKey { static const String customColor = 'customColor'; // 自定义主题色 static const String iosTransition = 'iosTransition'; // ios路由 static const String enableSingleRow = 'enableSingleRow'; // 首页单列 + static const String displayMode = 'displayMode'; + static const String customRows = 'customRows'; // 自定义列 + static const String enableMYBar = 'enableMYBar'; } class LocalCacheKey { @@ -132,6 +149,17 @@ class LocalCacheKey { // static const String wbiKeys = 'wbiKeys'; static const String timeStamp = 'timeStamp'; + + // 弹幕相关设置 屏蔽类型 显示区域 透明度 字体大小 弹幕速度 + static const String danmakuBlockType = 'danmakuBlockType'; + static const String danmakuShowArea = 'danmakuShowArea'; + static const String danmakuOpacity = 'danmakuOpacity'; + static const String danmakuFontScale = 'danmakuFontScale'; + static const String danmakuSpeed = 'danmakuSpeed'; + + // 代理host port + static const String systemProxyHost = 'systemProxyHost'; + static const String systemProxyPort = 'systemProxyPort'; } class VideoBoxKey { @@ -141,4 +169,14 @@ class VideoBoxKey { static const String videoBrightness = 'videoBrightness'; // 倍速 static const String videoSpeed = 'videoSpeed'; + // 播放顺序 + static const String playRepeat = 'playRepeat'; + // 默认倍速 + static const String playSpeedDefault = 'playSpeedDefault'; + // 默认长按倍速 + static const String longPressSpeedDefault = 'longPressSpeedDefault'; + // 自定义倍速集合 + static const String customSpeedsList = 'customSpeedsList'; + // 画面填充比例 + static const String cacheVideoFit = 'cacheVideoFit'; } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 4ccd689a..8982c178 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -206,7 +206,7 @@ class Utils { static Future checkUpdata() async { SmartDialog.dismiss(); var currentInfo = await PackageInfo.fromPlatform(); - var result = await Request().get(Api.latestApp); + var result = await Request().get(Api.latestApp, extra: {'ua': 'mob'}); LatestDataModel data = LatestDataModel.fromJson(result.data); bool isUpdate = Utils.needUpdate(currentInfo.version, data.tagName!); if (isUpdate) { @@ -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'; + } } diff --git a/lib/utils/video_utils.dart b/lib/utils/video_utils.dart new file mode 100644 index 00000000..88faba3c --- /dev/null +++ b/lib/utils/video_utils.dart @@ -0,0 +1,36 @@ +import 'package:pilipala/models/video/play/url.dart'; + +class VideoUtils { + static String getCdnUrl(dynamic item) { + var backupUrl = ""; + var videoUrl = ""; + + /// 先获取backupUrl 一般是upgcxcode地址 播放更稳定 + if (item is VideoItem) { + backupUrl = item.backupUrl ?? ""; + videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); + } else if (item is AudioItem) { + backupUrl = item.backupUrl ?? ""; + videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); + } else { + return ""; + } + + /// issues #70 + if (videoUrl.contains(".mcdn.bilivideo") || + videoUrl.contains("/upgcxcode/")) { + //CDN列表 + var cdnList = { + 'ali': 'upos-sz-mirrorali.bilivideo.com', + 'cos': 'upos-sz-mirrorcos.bilivideo.com', + 'hw': 'upos-sz-mirrorhw.bilivideo.com', + }; + //取一个CDN + var cdn = cdnList['ali'] ?? ""; + var reg = RegExp(r'(http|https)://(.*?)/upgcxcode/'); + videoUrl = videoUrl.replaceAll(reg, "https://$cdn/upgcxcode/"); + } + + return videoUrl; + } +} diff --git a/pubspec.lock b/pubspec.lock index b9a207a2..8d49751c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: "direct main" description: name: animations - sha256: fe8a6bdca435f718bb1dc8a11661b2c22504c6da40ef934cee8327ed77934164 + sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70 url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" appscheme: dependency: "direct main" description: @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "21afe4333076c02877d14f4a89df111e658a6d466cbfc802eb705eb91bd5adfd" + url: "https://pub.dev" + source: hosted + version: "1.5.0" async: dependency: transitive description: @@ -61,10 +69,17 @@ packages: dependency: "direct main" description: name: audio_service +<<<<<<< HEAD sha256: dc3d10682b688f4779e0f4662586f1385f0c09169a6bbc42dcc193b9d4b6113f url: "https://pub.dev" source: hosted version: "0.18.11" +======= + sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 + url: "https://pub.dev" + source: hosted + version: "0.18.12" +>>>>>>> main audio_service_platform_interface: dependency: transitive description: @@ -93,10 +108,18 @@ packages: dependency: "direct main" description: name: audio_video_progress_bar - sha256: "67f3a5ea70d48b48caaf29f5a0606284a6aa3a393736daf9e82bec985d2f9b70" + sha256: "3384875247cdbea748bd9ae8330631cd06a6cabfcda4945d45c9b406da92bc66" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.1" + auto_orientation: + dependency: "direct main" + description: + name: auto_orientation + sha256: cd56bb59b36fa54cc28ee254bc600524f022a4862f31d5ab20abd7bb1c54e678 + url: "https://pub.dev" + source: hosted + version: "2.3.1" boolean_selector: dependency: transitive description: @@ -173,26 +196,26 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15 + sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.3.0" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7 + sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0 + sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" characters: dependency: transitive description: @@ -289,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + csslib: + dependency: transitive + description: + name: csslib + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + url: "https://pub.dev" + source: hosted + version: "0.17.3" cupertino_icons: dependency: "direct main" description: @@ -341,26 +372,26 @@ packages: dependency: "direct main" description: name: dio - sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 + sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" url: "https://pub.dev" source: hosted - version: "5.3.2" + version: "5.3.3" dio_cookie_manager: dependency: "direct main" description: name: dio_cookie_manager - sha256: c4b7a693aa09efd694a5c5e12065daa5e026647b106245281ed1042b3ebefb8f + sha256: e79498b0f632897ff0c28d6e8178b4bc6e9087412401f618c31fa0904ace050d url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.1" dio_http2_adapter: dependency: "direct main" description: name: dio_http2_adapter - sha256: ada89ff1ea108c191188e5112b1ae87f12f5995f8cbf50afe87a736e36f1a5af + sha256: "3d81128cf389649ae6ac5cce23bcf5f9b254882b7f27185ca3b0d443ee9b825c" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.1+1" dismissible_page: dependency: "direct main" description: @@ -373,10 +404,10 @@ packages: dependency: "direct main" description: name: dynamic_color - sha256: de4798a7069121aee12d5895315680258415de9b00e717723a1bd73d58f0126d + sha256: "8b8bd1d798bd393e11eddeaa8ae95b12ff028bf7d5998fc5d003488cd5f4ce2f" url: "https://pub.dev" source: hosted - version: "1.6.6" + version: "1.6.8" easy_debounce: dependency: "direct main" description: @@ -385,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + encrypt: + dependency: "direct main" + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" extended_image: dependency: "direct main" description: @@ -457,19 +496,19 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + floating: + dependency: "direct main" + description: + name: floating + sha256: d9d563089e34fbd714ffdcdd2df447ec41b40c9226dacae6b4f78847aef8b991 + url: "https://pub.dev" + source: hosted + version: "2.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_blurhash: - dependency: transitive - description: - name: flutter_blurhash - sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6" - url: "https://pub.dev" - source: hosted - version: "0.7.0" flutter_cache_manager: dependency: transitive description: @@ -486,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "02ad69e813ecfc0728a455e4bf892b9379983e050722b1dce00192ee2e41d1ee" + url: "https://pub.dev" + source: hosted + version: "3.0.0-beta.2" flutter_launcher_icons: dependency: "direct dev" description: @@ -519,10 +566,10 @@ packages: dependency: "direct main" description: name: flutter_smart_dialog - sha256: "8ba9eeb5b0b380bec368c5c8a324e1dab0cd88965c2dd83e64237441140bc599" + sha256: "8ffa51d55591227dbfe9fc2b1ff396b37bec7d09c241d875b9b932db99d2d5ea" url: "https://pub.dev" source: hosted - version: "4.9.3+2" + version: "4.9.4" flutter_svg: dependency: "direct main" description: @@ -540,10 +587,10 @@ packages: dependency: "direct main" description: name: flutter_volume_controller - sha256: "7f88cb046b00fd80e98bcb7926b9e3879f004f30905109fdf6c5d09b8d28eb2e" + sha256: "1161957826183b46916adb4f1c9f91befce0d8415bd3fcd781f7faed9df62d46" url: "https://pub.dev" source: hosted - version: "1.2.7" + version: "1.3.0" flutter_web_plugins: dependency: transitive description: flutter @@ -589,6 +636,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + gt3_flutter_plugin: + dependency: "direct main" + description: + name: gt3_flutter_plugin + sha256: f12bff2bfbcf27467833f8d564dcc24ee2f1b3254a7c7cf5eb2c4590baf11cc1 + url: "https://pub.dev" + source: hosted + version: "0.0.8" hive: dependency: "direct main" description: @@ -613,6 +668,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + html: + dependency: "direct main" + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" http: dependency: transitive description: @@ -661,14 +724,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.17" - image_gallery_saver: - dependency: "direct main" - description: - name: image_gallery_saver - sha256: "0aba74216a4d9b0561510cb968015d56b701ba1bd94aace26aacdd8ae5761816" - url: "https://pub.dev" - source: hosted - version: "2.0.3" intl: dependency: transitive description: @@ -733,22 +788,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.dev" + source: hosted + version: "1.0.2" loading_more_list: dependency: "direct main" description: name: loading_more_list - sha256: aa680edc81cf024c394dccfa7ba1db701a5efb23ec1b8c657308428ce9da11d1 + sha256: "6b49eb935345d6cf291e0367d3c238ef0a525a08b671ee41e09ee67d41888a7a" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "6.0.0" loading_more_list_library: dependency: transitive description: name: loading_more_list_library - sha256: "31348925a98748ffe04f661e4b47df37103fabad39442064fcf59148a5fee2dd" + sha256: de6b57edbab83022180f053ec3f598dd5e1192cfd6a285882b8155e3cb5dc581 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" logging: dependency: transitive description: @@ -777,66 +840,74 @@ packages: dependency: "direct main" description: name: media_kit - sha256: "66f04934bcadf592f24d829127471e4dc304de8e9bba5795ade2f3e95552ebfc" + sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" url: "https://pub.dev" source: hosted - version: "1.1.6" + version: "1.1.10+1" media_kit_libs_android_video: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_android_video - sha256: "498a5062bc5f000bd23ada3be788ea886ab32c52f7a8252dde1264ca019b819b" + sha256: "9dd8012572e4aff47516e55f2597998f0a378e3d588d0fad0ca1f11a53ae090c" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.6" media_kit_libs_ios_video: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_ios_video - sha256: fed403dc9d54462e51ee80e0cb23c12a53fadea9a8fa18aca2de9054176d1159 + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" media_kit_libs_linux: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_linux - sha256: "3b7c272179639a914dc8a50bf8a3f2df0e9a503bd727c88fab499dbdf6cb1eb8" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - media_kit_libs_macos_video: - dependency: "direct main" - description: - name: media_kit_libs_macos_video - sha256: c06e831f3c22a45296d375788d9bc07871b448f8e9ec98d77b11e5e118a83fb2 + sha256: e186891c31daa6bedab4d74dcdb4e8adfccc7d786bfed6ad81fe24a3b3010310 url: "https://pub.dev" source: hosted version: "1.1.3" - media_kit_libs_windows_video: + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "3688e0c31482074578652bf038ce6301a5d21e1eda6b54fc3117ffeb4bdba067" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + media_kit_libs_windows_video: + dependency: transitive description: name: media_kit_libs_windows_video - sha256: "923f068344d7d200184e0aaa2597f3de6c05982a3b1f18035d842ab53f2a1350" + sha256: "7bace5f35d9afcc7f9b5cdadb7541d2191a66bb3fc71bfa11c1395b3360f6122" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + media_kit_native_event_loop: + dependency: transitive + description: + name: media_kit_native_event_loop + sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e url: "https://pub.dev" source: hosted version: "1.0.8" - media_kit_native_event_loop: - dependency: "direct main" - description: - name: media_kit_native_event_loop - sha256: e37ce6fb5fa71b8cf513c6a6cd591367743a342a385e7da621a047dd8ef6f4a4 - url: "https://pub.dev" - source: hosted - version: "1.0.7" media_kit_video: dependency: "direct main" description: name: media_kit_video - sha256: "809a3797da7d49fad85f139555b352dd615f9d2da6ae9f1745c6978963491bae" + sha256: c048d11a19e379aebbe810647636e3fc6d18374637e2ae12def4ff8a4b99a882 url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.2.4" meta: dependency: transitive description: @@ -874,10 +945,10 @@ packages: dependency: transitive description: name: octo_image - sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143" + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "2.0.0" package_config: dependency: transitive description: @@ -922,66 +993,66 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.1.1" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.2.0" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "916731ccbdce44d545414dd9961f26ba5fbaa74bcbb55237d8e65a623a8c7297" + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: ffbb8cc9ed2c9ec0e4b7a541e56fd79b138e8f47d2fb86815f15358a349b3b57 + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.1.11" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.1.1" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "2.2.1" permission_handler: dependency: "direct main" description: name: permission_handler - sha256: "63e5216aae014a72fe9579ccd027323395ce7a98271d9defa9d57320d001af81" + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" url: "https://pub.dev" source: hosted - version: "10.4.3" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "2ffaf52a21f64ac9b35fe7369bb9533edbd4f698e5604db8645b1064ff4cf221" + sha256: f9fddd3b46109bd69ff3f9efa5006d2d309b7aec0f3c1c5637a60a2d5659e76e url: "https://pub.dev" source: hosted - version: "10.3.3" + version: "11.1.0" permission_handler_apple: dependency: transitive description: @@ -994,10 +1065,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: "7c6b1500385dd1d2ca61bb89e2488ca178e274a69144d26bbd65e33eae7c02a9" + sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" url: "https://pub.dev" source: hosted - version: "3.11.3" + version: "3.12.0" permission_handler_windows: dependency: transitive description: @@ -1094,14 +1165,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + saver_gallery: + dependency: "direct main" + description: + name: saver_gallery + sha256: "3131bba4257f69901437c0f1ebd692201ca5f34512d42667513a3802f1c171d1" + url: "https://pub.dev" + source: hosted + version: "2.0.1" screen_brightness: dependency: "direct main" description: name: screen_brightness - sha256: "62fd61a64e68b32b98b840bad7d8b6822bbc40e63c2b569a5f85528484c86b41" + sha256: ed8da4a4511e79422fc1aa88138e920e4008cd312b72cdaa15ccb426c0faaedd url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.2.2+1" screen_brightness_android: dependency: transitive description: @@ -1267,6 +1346,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + system_proxy: + dependency: "direct main" + description: + name: system_proxy + sha256: bbdfc9736a963409941fb0e7c494606c1f13c2be34de15833ee385da83cf7ab0 + url: "https://pub.dev" + source: hosted + version: "0.1.0" term_glyph: dependency: transitive description: @@ -1327,10 +1414,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e" + sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" url: "https://pub.dev" source: hosted - version: "6.1.12" + version: "6.1.14" url_launcher_android: dependency: transitive description: @@ -1388,7 +1475,7 @@ packages: source: hosted version: "3.0.7" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" diff --git a/pubspec.yaml b/pubspec.yaml index 59b6e2dc..1161e30c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.6 +version: 1.0.11 environment: sdk: ">=2.19.6 <3.0.0" @@ -36,31 +36,31 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.5 # 动态取色 - dynamic_color: ^1.6.6 + dynamic_color: ^1.6.8 get: ^4.6.5 # 网络 - dio: ^5.3.0 + dio: ^5.3.3 cookie_jar: ^4.0.8 - dio_http2_adapter: ^2.3.1 - dio_cookie_manager: ^3.1.0 + dio_cookie_manager: ^3.1.1 connectivity_plus: ^4.0.1 + dio_http2_adapter: ^2.3.1+1 # 图片 - cached_network_image: ^3.2.3 + cached_network_image: ^3.3.0 extended_image: ^8.0.2 - image_gallery_saver: ^2.0.3 + saver_gallery: ^2.0.1 # 存储 - path_provider: ^2.0.14 + path_provider: ^2.1.1 hive: ^2.2.3 hive_flutter: ^1.1.0 # 设备信息 device_info_plus: ^9.0.2 # 权限 - permission_handler: ^10.4.3 + permission_handler: ^11.0.1 # 分享 share_plus: ^7.0.2 # cookie 管理 @@ -70,43 +70,43 @@ dependencies: # 解决sliver滑动不同步 extended_nested_scroll_view: ^6.1.2 # 上拉加载 - loading_more_list: ^5.0.3 + loading_more_list: ^6.0.0 # 下拉刷新 pull_to_refresh_notification: ^3.0.1 # 图标 font_awesome_flutter: ^10.4.0 # toast - flutter_smart_dialog: ^4.9.3+2 + flutter_smart_dialog: ^4.9.4 # 下滑关闭 dismissible_page: ^1.0.2 custom_sliding_segmented_control: ^1.7.5 # 加密 crypto: ^3.0.3 + encrypt: ^5.0.3 # 视频播放器 - media_kit: ^1.1.4 # Primary package. - media_kit_video: ^1.1.5 # For video rendering. - media_kit_native_event_loop: ^1.0.7 # Support for higher number of concurrent instances & better performance. - media_kit_libs_android_video: ^1.3.2 # Android package for video native libraries. - media_kit_libs_ios_video: ^1.1.3 # iOS package for video native libraries. - media_kit_libs_macos_video: ^1.1.3 # macOS package for video native libraries. - media_kit_libs_windows_video: ^1.0.7 # Windows package for video native libraries. - media_kit_libs_linux: ^1.1.1 + media_kit: ^1.1.10 # Primary package. + media_kit_video: ^1.2.4 # For video rendering. + media_kit_libs_video: ^1.0.4 + + # 媒体通知 + audio_service: ^0.18.12 + audio_session: ^0.1.16 # 音量、亮度、屏幕控制 - flutter_volume_controller: ^1.2.7 - screen_brightness: ^0.2.2 + flutter_volume_controller: ^1.3.0 + screen_brightness: ^0.2.2+1 wakelock_plus: ^1.1.1 universal_platform: ^1.0.0+1 # 进度条 - audio_video_progress_bar: ^1.0.1 - # auto_orientation: ^2.3.1 + audio_video_progress_bar: ^2.0.1 + auto_orientation: ^2.3.1 protobuf: ^3.0.0 - animations: ^2.0.7 + animations: ^2.0.8 # 获取appx信息 package_info_plus: ^4.1.0 - url_launcher: ^6.1.12 + url_launcher: ^6.1.14 flutter_svg: ^2.0.7 # 防抖节流 easy_debounce: ^2.0.3 @@ -123,8 +123,18 @@ dependencies: status_bar_control: ^3.2.1 # 音频播放 just_audio: ^0.9.34 - audio_service: ^0.18.11 - audio_session: any + # 代理 + system_proxy: ^0.1.0 + # pip + floating: ^2.0.1 + # html解析 + html: ^0.15.4 + # html渲染 + flutter_html: ^3.0.0-beta.2 + # 极验 + gt3_flutter_plugin: ^0.0.8 + uuid: ^3.0.7 + dev_dependencies: flutter_test: