From 08a33d9ce502b9c899b25de3084b25767fa84d25 Mon Sep 17 00:00:00 2001 From: My-Responsitories <107370289+My-Responsitories@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:40:12 +0800 Subject: [PATCH] feat: musicDetail (#1157) * feat: musicDetail * opt: marquee --- .../interactiveviewer_gallery.dart | 2 +- lib/common/widgets/marquee.dart | 348 +++++++++ lib/http/api.dart | 8 + lib/http/music.dart | 65 ++ lib/models_new/music/bgm_detail.dart | 272 +++++++ lib/models_new/music/bgm_recommend_list.dart | 126 ++++ lib/models_new/video/video_tag/data.dart | 3 + lib/pages/article/controller.dart | 7 - .../common/dyn/common_dyn_controller.dart | 5 +- lib/pages/dynamics_detail/controller.dart | 7 - lib/pages/match_info/controller.dart | 10 +- lib/pages/music/controller.dart | 41 ++ lib/pages/music/video/controller.dart | 30 + lib/pages/music/video/view.dart | 127 ++++ lib/pages/music/view.dart | 667 ++++++++++++++++++ .../music/widget/music_video_card_h.dart | 147 ++++ lib/pages/video/introduction/ugc/view.dart | 24 +- lib/pages/video/widgets/header_control.dart | 63 +- lib/router/app_pages.dart | 2 + lib/utils/app_scheme.dart | 28 +- pubspec.lock | 8 - pubspec.yaml | 2 +- 22 files changed, 1891 insertions(+), 101 deletions(-) create mode 100644 lib/common/widgets/marquee.dart create mode 100644 lib/http/music.dart create mode 100644 lib/models_new/music/bgm_detail.dart create mode 100644 lib/models_new/music/bgm_recommend_list.dart create mode 100644 lib/pages/music/controller.dart create mode 100644 lib/pages/music/video/controller.dart create mode 100644 lib/pages/music/video/view.dart create mode 100644 lib/pages/music/view.dart create mode 100644 lib/pages/music/widget/music_video_card_h.dart diff --git a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart index 4631323a..d2da8e7a 100644 --- a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart +++ b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart @@ -38,7 +38,7 @@ typedef IndexedFocusedWidgetBuilder = typedef IndexedTagStringBuilder = String Function(int index); -class InteractiveviewerGallery extends StatefulWidget { +class InteractiveviewerGallery extends StatefulWidget { const InteractiveviewerGallery({ super.key, required this.sources, diff --git a/lib/common/widgets/marquee.dart b/lib/common/widgets/marquee.dart new file mode 100644 index 00000000..dfb58404 --- /dev/null +++ b/lib/common/widgets/marquee.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class MarqueeText extends StatelessWidget { + final double maxWidth; + final String text; + final TextStyle? style; + + const MarqueeText( + this.text, { + required this.maxWidth, + this.style, + super.key, + }); + + @override + Widget build(BuildContext context) { + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: style, + ), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(maxWidth: maxWidth); + final child = Text( + text, + style: style, + maxLines: 1, + textDirection: TextDirection.ltr, + ); + if (textPainter.didExceedMaxLines) { + return SingleWidgetMarquee( + child, + duration: const Duration(seconds: 5), + bounce: true, + ); + } else { + return child; + } + } +} + +class SingleWidgetMarquee extends StatefulWidget { + final Widget child; + final Duration? duration; + final bool bounce; + final double spacing; + + const SingleWidgetMarquee( + this.child, { + super.key, + this.duration, + this.bounce = false, + this.spacing = 0, + }); + + @override + State createState() => _SingleWidgetMarqueeState(); +} + +class _SingleWidgetMarqueeState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: widget.duration, + reverseDuration: widget.duration, + )..repeat(reverse: widget.bounce); + + @override + Widget build(BuildContext context) => widget.bounce + ? BounceMarquee( + animation: _controller, + spacing: widget.spacing, + child: widget.child, + ) + : NormalMarquee( + animation: _controller, + spacing: widget.spacing, + child: widget.child, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +abstract class Marquee extends SingleChildRenderObjectWidget { + final Axis direction; + final Clip clipBehavior; + final double spacing; + final Animation animation; + + const Marquee({ + super.key, + required this.animation, + required super.child, + this.direction = Axis.horizontal, + this.clipBehavior = Clip.hardEdge, + this.spacing = 0, + }); + + @override + void updateRenderObject( + BuildContext context, + covariant MarqueeRender renderObject, + ) { + renderObject + ..direction = direction + ..clipBehavior = clipBehavior + ..animation = animation + ..spacing = spacing; + } +} + +class NormalMarquee extends Marquee { + const NormalMarquee({ + super.key, + required super.animation, + required super.child, + super.direction, + super.clipBehavior, + super.spacing, + }); + + @override + RenderObject createRenderObject(BuildContext context) => _NormalMarqueeRender( + direction: direction, + animation: animation, + clipBehavior: clipBehavior, + spacing: spacing, + ); +} + +class BounceMarquee extends Marquee { + const BounceMarquee({ + super.key, + required super.animation, + required super.child, + super.direction, + super.clipBehavior, + super.spacing, + }); + + @override + RenderObject createRenderObject(BuildContext context) => _BounceMarqueeRender( + direction: direction, + animation: animation, + clipBehavior: clipBehavior, + spacing: spacing, + ); +} + +abstract class MarqueeRender extends RenderBox + with RenderObjectWithChildMixin { + MarqueeRender({ + required Axis direction, + required Animation animation, + required this.clipBehavior, + required this.spacing, + }) : _direction = direction, + _animation = animation, + assert(spacing.isFinite && !spacing.isNaN); + + Clip clipBehavior; + double spacing; + + Axis _direction; + Axis get direction => _direction; + set direction(Axis value) { + if (_direction == value) return; + _direction = value; + markNeedsLayout(); + } + + Animation _animation; + Animation get animation => _animation; + set animation(Animation value) { + if (_animation == value) return; + if (_listened) { + _animation.removeListener(markNeedsPaint); + value.addListener(markNeedsPaint); + } + _animation = value; + } + + @override + void detach() { + _removeListener(); + super.detach(); + } + + bool _listened = false; + void _addListener() { + if (!_listened) { + _animation.addListener(markNeedsPaint); + _listened = true; + } + } + + void _removeListener() { + if (_listened) { + _animation.removeListener(markNeedsPaint); + _listened = false; + } + } + + late double _distance; + + @override + void performLayout() { + final child = this.child; + if (child == null) { + size = constraints.smallest; + return; + } + + if (_direction == Axis.horizontal) { + child.layout( + BoxConstraints(maxHeight: constraints.maxHeight), + parentUsesSize: true, + ); + size = constraints.constrain(child.size); + _distance = child.size.width - size.width; + if (spacing.isNegative) spacing *= -size.width; + } else { + child.layout( + BoxConstraints(maxWidth: constraints.maxWidth), + parentUsesSize: true, + ); + size = constraints.constrain(child.size); + _distance = child.size.height - size.height; + if (spacing.isNegative) spacing *= -size.height; + } + if (_distance > 0) { + _addListener(); + } else { + _removeListener(); + } + } + + @override + bool get isRepaintBoundary => true; + + void paintCenter(PaintingContext context, Offset offset) { + if (_direction == Axis.horizontal) { + context.paintChild(child!, Offset(offset.dx - _distance / 2, offset.dy)); + } else { + context.paintChild(child!, Offset(offset.dx, offset.dy - _distance / 2)); + } + } +} + +class _BounceMarqueeRender extends MarqueeRender { + _BounceMarqueeRender({ + required super.direction, + required super.animation, + required super.clipBehavior, + required super.spacing, + }); + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + + final tick = _animation.value; + + if (_distance > 0) { + final helfSpacing = spacing / 2.0; + void paintChild() { + if (_direction == Axis.horizontal) { + context.paintChild( + child!, + Offset( + offset.dx + helfSpacing - tick * (_distance + spacing), + offset.dy, + ), + ); + } else { + context.paintChild( + child!, + Offset( + offset.dx, + offset.dy + helfSpacing - tick * (_distance + spacing), + ), + ); + } + } + + if (clipBehavior == Clip.none) { + paintChild(); + } else { + final rect = Rect.fromLTRB(0, 0, size.width, size.height); + context.clipRectAndPaint(rect, clipBehavior, rect, paintChild); + } + } else { + paintCenter(context, offset); + } + } +} + +class _NormalMarqueeRender extends MarqueeRender { + _NormalMarqueeRender({ + required super.direction, + required super.animation, + required super.clipBehavior, + required super.spacing, + }); + + @override + void paint(PaintingContext context, Offset offset) { + final child = this.child; + if (child == null) return; + + final tick = _animation.value; + + if (_distance > 0) { + void paintChild() { + if (_direction == Axis.horizontal) { + final w = child.size.width + spacing; + final dx = tick * w; + context.paintChild(child, Offset(offset.dx - dx, offset.dy)); + if (dx > _distance) { + context.paintChild(child, Offset(offset.dx + w - dx, offset.dy)); + } + } else { + final h = child.size.height + spacing; + final dy = tick * h; + context.paintChild(child, Offset(offset.dx, offset.dy - dy)); + if (dy > _distance) { + context.paintChild(child, Offset(offset.dx, offset.dy + h - dy)); + } + } + } + + if (clipBehavior == Clip.none) { + paintChild(); + } else { + final rect = Rect.fromLTRB(0, 0, size.width, size.height); + context.clipRectAndPaint(rect, clipBehavior, rect, paintChild); + } + } else { + paintCenter(context, offset); + } + } +} diff --git a/lib/http/api.dart b/lib/http/api.dart index 331502d6..b55b1cd9 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -951,4 +951,12 @@ class Api { static const String loginDevices = '${HttpString.passBaseUrl}/x/safecenter/user_login_devices'; + + static const String bgmDetail = '/x/copyright-music-publicity/bgm/detail'; + + static const String wishUpdate = + '/x/copyright-music-publicity/bgm/wish/update'; + + static const String bgmRecommend = + '/x/copyright-music-publicity/bgm/recommend_list'; } diff --git a/lib/http/music.dart b/lib/http/music.dart new file mode 100644 index 00000000..9e0fc15e --- /dev/null +++ b/lib/http/music.dart @@ -0,0 +1,65 @@ +import 'package:PiliPlus/http/api.dart'; +import 'package:PiliPlus/http/init.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/music/bgm_detail.dart'; +import 'package:PiliPlus/models_new/music/bgm_recommend_list.dart'; +import 'package:PiliPlus/utils/accounts.dart'; +import 'package:PiliPlus/utils/wbi_sign.dart'; +import 'package:dio/dio.dart'; + +class MusicHttp { + static Future> bgmDetail(String musicId) async { + final res = await Request().get( + Api.bgmDetail, + queryParameters: await WbiSign.makSign({ + 'music_id': musicId, + 'relation_from': 'bgm_page', + }), + ); + if (res.data['code'] == 0) { + return Success(MusicDetail.fromJson(res.data['data'])); + } else { + return Error(res.data['message']); + } + } + + static Future> wishUpdate( + String musicId, + bool hasLike, + ) async { + final res = await Request().post( + Api.wishUpdate, + data: { + 'music_id': musicId, + 'state': hasLike ? 2 : 1, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return const Success(null); + } else { + return Error(res.data['message']); + } + } + + static Future?>> bgmRecommend( + String musicId, + ) async { + final res = await Request().get( + Api.bgmRecommend, + queryParameters: { + 'music_id': musicId, + }, + ); + if (res.data['code'] == 0) { + return Success( + (res.data['data']?['list'] as List?) + ?.map((i) => BgmRecommend.fromJson(i)) + .toList(), + ); + } else { + return Error(res.data['message']); + } + } +} diff --git a/lib/models_new/music/bgm_detail.dart b/lib/models_new/music/bgm_detail.dart new file mode 100644 index 00000000..85104af9 --- /dev/null +++ b/lib/models_new/music/bgm_detail.dart @@ -0,0 +1,272 @@ +import 'package:PiliPlus/models/model_owner.dart'; +import 'package:PiliPlus/utils/extension.dart'; + +class MusicDetail { + MusicDetail({ + required this.musicTitle, + required this.originArtist, + required this.originArtistList, + required this.mvAid, + required this.mvCid, + required this.mvBvid, + required this.mvIndexOrder, + required this.mvFav, + required this.mvLikes, + required this.mvShares, + required this.mvCover, + required this.bgColor, + required this.mvLyric, + required this.supportListen, + required this.wishListen, + required this.wishCount, + required this.musicShares, + required this.musicSource, + required this.album, + required this.artists, + required this.artistsList, + required this.listenPv, + required this.achievement, + required this.musicRank, + required this.maxListId, + required this.showChosen, + required this.hotSongHeat, + required this.hotSongRank, + required this.creationRank, + required this.musicOutUrl, + // required this.abTest, + required this.musicComment, + // required this.musicMaterial, + required this.isNextgenActivity, + required this.isOriginal, + required this.musicHot, + required this.musicRelation, + required this.musicPublish, + // required this.musicAchievementTimeline, + required this.flowAttr, + }); + + final String? musicTitle; + final String? originArtist; + final String? originArtistList; + final int? mvAid; + final int? mvCid; + final String? mvBvid; + final int? mvIndexOrder; + final int? mvFav; + final int? mvLikes; + final int? mvShares; + final String? mvCover; + final String? bgColor; + final String? mvLyric; + final bool? supportListen; + bool? wishListen; + int? wishCount; + final int? musicShares; + final String? musicSource; + final String? album; + final List? artists; + final List? artistsList; + final int? listenPv; + final List? achievement; + final String? musicRank; + final int? maxListId; + final bool? showChosen; + final HotSongHeat? hotSongHeat; + final Rank? hotSongRank; + final Rank? creationRank; + final String? musicOutUrl; + // final dynamic abTest; + final MusicComment? musicComment; + // final dynamic musicMaterial; + final int? isNextgenActivity; + final int? isOriginal; + final int? musicHot; + final int? musicRelation; + final String? musicPublish; + // final List? musicAchievementTimeline; + final FlowAttr? flowAttr; + + factory MusicDetail.fromJson(Map json) { + return MusicDetail( + musicTitle: json["music_title"], + originArtist: json["origin_artist"], + originArtistList: json["origin_artist_list"], + mvAid: json["mv_aid"], + mvCid: json["mv_cid"], + mvBvid: json["mv_bvid"], + mvIndexOrder: json["mv_index_order"], + mvFav: json["mv_fav"], + mvLikes: json["mv_likes"], + mvShares: json["mv_shares"], + mvCover: json["mv_cover"], + bgColor: json["bg_color"], + mvLyric: json["mv_lyric"], + supportListen: json["support_listen"], + wishListen: json["wish_listen"], + wishCount: json["wish_count"], + musicShares: json["music_shares"], + musicSource: json["music_source"], + album: json["album"], + artists: (json["artists"] as List?) + ?.map((x) => Artist.fromJson(x)) + .toList(), + artistsList: (json["artists_list"] as List?) + ?.map((x) => Artist.fromJson(x)) + .toList(), + listenPv: json["listen_pv"], + achievement: (json["achievement"] as List?)?.fromCast(), + musicRank: json["music_rank"], + maxListId: json["max_list_id"], + showChosen: json["show_chosen"], + hotSongHeat: json["hot_song_heat"] == null + ? null + : HotSongHeat.fromJson(json["hot_song_heat"]), + hotSongRank: json["hot_song_rank"] == null + ? null + : Rank.fromJson(json["hot_song_rank"]), + creationRank: json["creation_rank"] == null + ? null + : Rank.fromJson(json["creation_rank"]), + musicOutUrl: json["music_out_url"], + musicComment: json["music_comment"] == null + ? null + : MusicComment.fromJson(json["music_comment"]), + isNextgenActivity: json["is_nextgen_activity"], + isOriginal: json["is_original"], + musicHot: json["music_hot"], + musicRelation: json["music_relation"], + musicPublish: json["music_publish"], + flowAttr: json["flow_attr"] == null + ? null + : FlowAttr.fromJson(json["flow_attr"]), + ); + } +} + +class Artist extends Owner { + String? identity; + int? identifyType; + + Artist.fromJson(Map json) : super.fromJson(json) { + identity = json["identity"]; + identifyType = json["identify_type"]; + } +} + +class Rank { + Rank({ + required this.lastUpdate, + required this.highestRank, + required this.onListTimes, + required this.listDetail, + }); + + final int? lastUpdate; + final int? highestRank; + final int? onListTimes; + final List? listDetail; + + factory Rank.fromJson(Map json) { + return Rank( + lastUpdate: json["last_update"], + highestRank: json["highest_rank"], + onListTimes: json["on_list_times"], + listDetail: (json["list_detail"] as List?) + ?.map((x) => ListDetail.fromJson(x)) + .toList(), + ); + } +} + +class ListDetail { + ListDetail({ + required this.date, + required this.rank, + }); + + final int? date; + final int? rank; + + factory ListDetail.fromJson(Map json) { + return ListDetail( + date: json["date"], + rank: json["rank"], + ); + } +} + +class FlowAttr { + FlowAttr({ + required this.noShare, + required this.noComment, + }); + + final bool? noShare; + final bool? noComment; + + factory FlowAttr.fromJson(Map json) { + return FlowAttr( + noShare: json["no_share"], + noComment: json["no_comment"], + ); + } +} + +class HotSongHeat { + HotSongHeat({ + required this.lastHeat, + required this.songHeat, + }); + + final int? lastHeat; + final List? songHeat; + + factory HotSongHeat.fromJson(Map json) { + return HotSongHeat( + lastHeat: json["last_heat"], + songHeat: (json["song_heat"] as List?)?.reversed + .map((x) => SongHeat.fromJson(x)) + .toList(), + ); + } +} + +class SongHeat { + SongHeat({ + required this.date, + required this.heat, + }); + + final int date; + final int heat; + + factory SongHeat.fromJson(Map json) { + return SongHeat( + date: json["date"], + heat: json["heat"], + ); + } +} + +class MusicComment { + MusicComment({ + required this.state, + required this.nums, + required this.oid, + required this.pageType, + }); + + final int? state; + final int? nums; + final int? oid; + final int? pageType; + + factory MusicComment.fromJson(Map json) { + return MusicComment( + state: json["state"], + nums: json["nums"], + oid: json["oid"], + pageType: json["page_type"], + ); + } +} diff --git a/lib/models_new/music/bgm_recommend_list.dart b/lib/models_new/music/bgm_recommend_list.dart new file mode 100644 index 00000000..ea84b28d --- /dev/null +++ b/lib/models_new/music/bgm_recommend_list.dart @@ -0,0 +1,126 @@ +class BgmRecommend { + BgmRecommend({ + required this.aid, + required this.bvid, + required this.indexOrder, + required this.cid, + required this.cover, + required this.title, + required this.mid, + required this.upNickName, + required this.play, + required this.vt, + required this.isVt, + required this.danmu, + required this.duration, + required this.label, + required this.labelList, + required this.isTop, + required this.showType, + required this.clickType, + required this.jumpUrl, + required this.vtDisplay, + required this.aidSource, + required this.tid, + required this.subTid, + required this.subTagName, + required this.coverMark, + // required this.districtLabel, + }); + + final int? aid; + final String? bvid; + final int? indexOrder; + final int? cid; + final String? cover; + final String? title; + final int? mid; + final String? upNickName; + final int? play; + final int? vt; + final int? isVt; + final int? danmu; + final int? duration; + final String? label; + final List? labelList; + final bool? isTop; + final int? showType; + final int? clickType; + final String? jumpUrl; + final String? vtDisplay; + final int? aidSource; + final int? tid; + final int? subTid; + final String? subTagName; + final CoverMark? coverMark; + // final dynamic districtLabel; + + factory BgmRecommend.fromJson(Map json) { + return BgmRecommend( + aid: json["aid"], + bvid: json["bvid"], + indexOrder: json["index_order"], + cid: json["cid"], + cover: json["cover"], + title: json["title"], + mid: json["mid"], + upNickName: json["up_nick_name"], + play: json["play"], + vt: json["vt"], + isVt: json["is_vt"], + danmu: json["danmu"], + duration: json["duration"], + label: json["label"], + labelList: (json["label_list"] as List?) + ?.map((x) => LabelList.fromJson(x)) + .toList(), + isTop: json["is_top"], + showType: json["show_type"], + clickType: json["click_type"], + jumpUrl: json["jump_url"], + vtDisplay: json["vt_display"], + aidSource: json["aid_source"], + tid: json["tid"], + subTid: json["sub_tid"], + subTagName: json["sub_tag_name"], + coverMark: json["cover_mark"] == null + ? null + : CoverMark.fromJson(json["cover_mark"]), + // districtLabel: json["district_label"], + ); + } +} + +class CoverMark { + CoverMark({ + required this.name, + required this.value, + }); + + final String? name; + final String? value; + + factory CoverMark.fromJson(Map json) { + return CoverMark( + name: json["name"], + value: json["value"], + ); + } +} + +class LabelList { + LabelList({ + required this.name, + required this.value, + }); + + final String? name; + final int? value; + + factory LabelList.fromJson(Map json) { + return LabelList( + name: json["name"], + value: json["value"], + ); + } +} diff --git a/lib/models_new/video/video_tag/data.dart b/lib/models_new/video/video_tag/data.dart index 0cb7cbad..87b6ee11 100644 --- a/lib/models_new/video/video_tag/data.dart +++ b/lib/models_new/video/video_tag/data.dart @@ -1,12 +1,14 @@ class VideoTagItem { int? tagId; String? tagName; + String? tagType; String? musicId; String? jumpUrl; VideoTagItem({ this.tagId, this.tagName, + this.tagType, this.musicId, this.jumpUrl, }); @@ -14,6 +16,7 @@ class VideoTagItem { factory VideoTagItem.fromJson(Map json) => VideoTagItem( tagId: json["tag_id"], tagName: json["tag_name"], + tagType: json["tag_type"], musicId: json["music_id"], jumpUrl: json["jump_url"], ); diff --git a/lib/pages/article/controller.dart b/lib/pages/article/controller.dart index ad384679..560412b5 100644 --- a/lib/pages/article/controller.dart +++ b/lib/pages/article/controller.dart @@ -1,5 +1,3 @@ -import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' - show MainListReply, ReplyInfo; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/fav.dart'; import 'package:PiliPlus/http/video.dart'; @@ -168,11 +166,6 @@ class ArticleController extends CommonDynController { } } - @override - List? getDataList(MainListReply response) { - return response.replies; - } - Future onFav() async { final favorite = stats.value?.favorite; bool isFav = favorite?.status == true; diff --git a/lib/pages/common/dyn/common_dyn_controller.dart b/lib/pages/common/dyn/common_dyn_controller.dart index 917a9721..a913199d 100644 --- a/lib/pages/common/dyn/common_dyn_controller.dart +++ b/lib/pages/common/dyn/common_dyn_controller.dart @@ -21,7 +21,7 @@ abstract class CommonDynController extends ReplyController late final horizontalPreview = Pref.horizontalPreview; late final List ratio = Pref.dynamicDetailRatio; - double offsetDy = 1; + final double offsetDy = 1; @override void onInit() { @@ -89,4 +89,7 @@ abstract class CommonDynController extends ReplyController cursorNext: cursorNext, offset: paginationReply?.nextOffset, ); + + @override + List? getDataList(MainListReply response) => response.replies; } diff --git a/lib/pages/dynamics_detail/controller.dart b/lib/pages/dynamics_detail/controller.dart index 0dd11f4d..02c8c8b5 100644 --- a/lib/pages/dynamics_detail/controller.dart +++ b/lib/pages/dynamics_detail/controller.dart @@ -1,5 +1,3 @@ -import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' - show MainListReply, ReplyInfo; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.dart'; @@ -47,9 +45,4 @@ class DynamicDetailController extends CommonDynController { replyType = commentType; queryData(); } - - @override - List? getDataList(MainListReply response) { - return response.replies; - } } diff --git a/lib/pages/match_info/controller.dart b/lib/pages/match_info/controller.dart index a4afb724..29fe37a1 100644 --- a/lib/pages/match_info/controller.dart +++ b/lib/pages/match_info/controller.dart @@ -1,4 +1,3 @@ -import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/match.dart'; import 'package:PiliPlus/models_new/match/match_info/contest.dart'; @@ -17,9 +16,11 @@ class MatchInfoController extends CommonDynController { final Rx> infoState = LoadingState.loading().obs; + @override + double get offsetDy => 2; + @override void onInit() { - offsetDy = 2; super.onInit(); getMatchInfo(); } @@ -31,9 +32,4 @@ class MatchInfoController extends CommonDynController { } infoState.value = res; } - - @override - List? getDataList(MainListReply response) { - return response.replies; - } } diff --git a/lib/pages/music/controller.dart b/lib/pages/music/controller.dart new file mode 100644 index 00000000..e7502186 --- /dev/null +++ b/lib/pages/music/controller.dart @@ -0,0 +1,41 @@ +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/http/music.dart'; +import 'package:PiliPlus/models_new/music/bgm_detail.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_controller.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:get/get.dart'; + +class MusicDetailController extends CommonDynController { + @override + late final int oid; + @override + late final int replyType; + + @override + dynamic get sourceId => oid.toString(); + + final infoState = LoadingState.loading().obs; + + late final String musicId; + + bool get showDynActionBar => Pref.showDynActionBar; + + @override + void onInit() { + super.onInit(); + musicId = Get.parameters['musicId']!; + getMusicDetail(); + } + + Future getMusicDetail() async { + final res = await MusicHttp.bgmDetail(musicId); + if (res.isSuccess) { + final comment = res.data.musicComment!; + oid = comment.oid!; + replyType = comment.pageType ?? 47; + count.value = comment.nums ?? -1; + queryData(); + } + infoState.value = res; + } +} diff --git a/lib/pages/music/video/controller.dart b/lib/pages/music/video/controller.dart new file mode 100644 index 00000000..cb490e70 --- /dev/null +++ b/lib/pages/music/video/controller.dart @@ -0,0 +1,30 @@ +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/http/music.dart'; +import 'package:PiliPlus/models_new/music/bgm_detail.dart'; +import 'package:PiliPlus/models_new/music/bgm_recommend_list.dart'; +import 'package:PiliPlus/pages/common/common_list_controller.dart'; +import 'package:get/get.dart'; + +class MusicRecommendController + extends CommonListController?, BgmRecommend> { + late final String musicId; + late final MusicDetail musicDetail; + + @override + void onInit() { + super.onInit(); + final Map args = Get.arguments; + musicId = args['id']; + musicDetail = args['detail']; + queryData(); + } + + @override + void checkIsEnd(int length) { + isEnd = true; + } + + @override + Future?>> customGetData() => + MusicHttp.bgmRecommend(musicId); +} diff --git a/lib/pages/music/video/view.dart b/lib/pages/music/video/view.dart new file mode 100644 index 00000000..ea0e0e91 --- /dev/null +++ b/lib/pages/music/video/view.dart @@ -0,0 +1,127 @@ +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/music/bgm_recommend_list.dart'; +import 'package:PiliPlus/pages/music/video/controller.dart'; +import 'package:PiliPlus/pages/music/widget/music_video_card_h.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MusicRecommandPage extends StatefulWidget { + const MusicRecommandPage({super.key}); + + @override + State createState() => _MusicRecommandPageState(); +} + +class _MusicRecommandPageState extends State + with GridMixin, SingleTickerProviderStateMixin { + late final _controller = Get.put( + MusicRecommendController(), + tag: Utils.generateRandomString(8), + ); + + late final _animation = AnimationController( + vsync: this, + duration: const Duration(seconds: 5), + reverseDuration: const Duration(seconds: 5), + )..repeat(reverse: true); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final padding = MediaQuery.viewPaddingOf(context); + return Material( + color: theme.colorScheme.surface, + child: refreshIndicator( + onRefresh: _controller.onRefresh, + child: CustomScrollView( + controller: _controller.scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + _buildAppBar(theme, padding), + SliverPadding( + padding: EdgeInsets.only( + top: 7, + left: padding.left, + right: padding.right, + bottom: padding.bottom + 100, + ), + sliver: Obx( + () => _buildBody(_controller.loadingState.value), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody(LoadingState?> loadingState) { + return switch (loadingState) { + Loading() => gridSkeleton, + Success(:var response) => + response?.isNotEmpty == true + ? SliverGrid.builder( + gridDelegate: gridDelegate, + itemBuilder: (context, index) => MusicVideoCardH( + videoItem: response[index], + animation: _animation, + ), + itemCount: response!.length, + ) + : HttpError(onReload: _controller.onReload), + Error(:var errMsg) => HttpError( + errMsg: errMsg, + onReload: _controller.onReload, + ), + }; + } + + Widget _buildAppBar(ThemeData theme, EdgeInsets padding) { + final info = _controller.musicDetail; + return SliverAppBar( + pinned: true, + title: Row( + spacing: 12, + children: [ + NetworkImgLayer( + width: 40, + height: 40, + src: info.mvCover, + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.musicTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + Obx(() { + final count = _controller.loadingState.value.dataOrNull?.length; + return count == null + ? const SizedBox.shrink() + : Text( + '共$count条视频', + style: theme.textTheme.labelMedium, + ); + }), + ], + ), + ], + ), + ); + } + + @override + void dispose() { + _animation.dispose(); + super.dispose(); + } +} diff --git a/lib/pages/music/view.dart b/lib/pages/music/view.dart new file mode 100644 index 00000000..d22da336 --- /dev/null +++ b/lib/pages/music/view.dart @@ -0,0 +1,667 @@ +import 'dart:math'; + +import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/custom_icon.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/marquee.dart'; +import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/http/music.dart'; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models/common/image_preview_type.dart'; +import 'package:PiliPlus/models/common/image_type.dart'; +import 'package:PiliPlus/models_new/music/bgm_detail.dart'; +import 'package:PiliPlus/pages/common/dyn/common_dyn_page.dart'; +import 'package:PiliPlus/pages/music/controller.dart'; +import 'package:PiliPlus/pages/music/video/view.dart'; +import 'package:PiliPlus/utils/accounts.dart'; +import 'package:PiliPlus/utils/date_util.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:PiliPlus/utils/num_util.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get/get.dart' hide ContextExtensionss; + +class MusicDetailPage extends StatefulWidget { + const MusicDetailPage({super.key}); + + @override + State createState() => _MusicDetailPageState(); +} + +class _MusicDetailPageState extends CommonDynPageState { + @override + final MusicDetailController controller = Get.put( + MusicDetailController(), + tag: Utils.generateRandomString(8), + ); + + @override + dynamic get arguments => null; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.sizeOf(context); + final maxWidth = size.width; + isPortrait = size.height >= maxWidth; + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: _buildAppBar(isPortrait, maxWidth), + body: Padding( + padding: EdgeInsets.only(left: padding.left, right: padding.right), + child: isPortrait + ? refreshIndicator( + onRefresh: controller.onRefresh, + child: _buildBody(theme, isPortrait, maxWidth), + ) + : _buildBody(theme, isPortrait, maxWidth), + ), + ); + } + + PreferredSizeWidget _buildAppBar(bool isPortrait, double maxWidth) => AppBar( + title: Padding( + padding: const EdgeInsets.only(right: 12), + child: Obx( + () { + final info = controller.infoState.value; + late final showTitle = controller.showTitle.value; + return info.isSuccess + ? AnimatedOpacity( + opacity: showTitle ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: IgnorePointer( + ignoring: !showTitle, + child: Row( + spacing: 8, + children: [ + NetworkImgLayer( + src: info.data.mvCover, + width: 40, + height: 40, + ), + Text(info.data.musicTitle!), + ], + ), + ), + ) + : const SizedBox(height: 40); + }, + ), + ), + actions: isPortrait + ? null + : [ + ratioWidget(maxWidth), + const SizedBox(width: 16), + ], + ); + + Widget _buildBody( + ThemeData theme, + bool isPortrait, + double maxWidth, + ) => Obx(() { + switch (controller.infoState.value) { + case Success(:final response): + double padding = max(maxWidth / 2 - Grid.smallCardWidth, 0); + final Widget child; + if (isPortrait) { + child = Padding( + padding: EdgeInsets.symmetric(horizontal: padding), + child: CustomScrollView( + controller: controller.scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: _buildCard(theme, response, maxWidth), + ), + SliverToBoxAdapter( + child: _buildChart(theme, response, maxWidth), + ), + buildReplyHeader(theme), + Obx(() => replyList(theme, controller.loadingState.value)), + ], + ), + ); + } else { + padding = padding / 4; + final flex = controller.ratio[0].toInt(); + final flex1 = controller.ratio[1].toInt(); + final leftWidth = + (maxWidth - this.padding.horizontal) * (flex / (flex + flex1)) - + padding; + child = Row( + children: [ + Expanded( + flex: flex, + child: CustomScrollView( + controller: controller.scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + left: padding, + ), + sliver: SliverToBoxAdapter( + child: _buildCard(theme, response, leftWidth), + ), + ), + SliverPadding( + padding: EdgeInsets.only( + left: padding, + bottom: this.padding.bottom + 100, + ), + sliver: SliverToBoxAdapter( + child: _buildChart(theme, response, leftWidth), + ), + ), + ], + ), + ), + Expanded( + flex: flex1, + child: Padding( + padding: EdgeInsets.only(right: padding), + child: Scaffold( + key: scaffoldKey, + backgroundColor: Colors.transparent, + resizeToAvoidBottomInset: false, + body: refreshIndicator( + onRefresh: controller.onRefresh, + child: CustomScrollView( + controller: controller + .scrollController, // debug: The provided ScrollController is attached to more than one ScrollPosition. + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + buildReplyHeader(theme), + Obx( + () => + replyList(theme, controller.loadingState.value), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + return Stack( + clipBehavior: Clip.none, + children: [ + child, + _buildBottom(theme, response), + ], + ); + default: + return const SizedBox.shrink(); + } + }); + + Widget _buildBottom(ThemeData theme, MusicDetail item) { + final outline = theme.colorScheme.outline; + + Widget textIconButton({ + required IconData icon, + required String text, + int? count, + bool status = false, + required VoidCallback onPressed, + IconData? activitedIcon, + }) { + final color = status ? theme.colorScheme.primary : outline; + return TextButton.icon( + onPressed: onPressed, + icon: Icon( + status ? activitedIcon : icon, + size: 16, + color: color, + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 15), + foregroundColor: outline, + ), + label: Text( + count != null ? NumUtil.numFormat(count) : text, + style: TextStyle(color: color), + ), + ); + } + + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: SlideTransition( + position: controller.fabAnim, + child: controller.showDynActionBar + ? Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only( + right: 14, + bottom: padding.bottom + 14, + ), + child: replyButton, + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 14, bottom: 14), + child: replyButton, + ), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outline.withValues( + alpha: 0.08, + ), + ), + ), + ), + padding: EdgeInsets.only(bottom: padding.bottom), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // TODO + // Expanded( + // child: textIconButton( + // icon: FontAwesomeIcons.shareFromSquare, + // text: '转发', + // count: item.musicShares, + // onPressed: () { + // final data = controller.infoState.value.dataOrNull; + // if (data != null) { + // showModalBottomSheet( + // context: context, + // isScrollControlled: true, + // useSafeArea: true, + // builder: (context) => RepostPanel( + // rid: controller.oid, + // dynType: null, + // pic: data.mvCover, + // title: data.musicTitle, + // ), + // ); + // } + // }, + // ), + // ), + Expanded( + child: textIconButton( + icon: CustomIcon.share_node, + text: '分享', + onPressed: () => Utils.shareText( + 'https://music.bilibili.com/h5/music-detail?music_id=${controller.musicId}', + ), + ), + ), + Expanded( + child: Builder( + builder: (context) => textIconButton( + icon: FontAwesomeIcons.thumbsUp, + activitedIcon: FontAwesomeIcons.solidThumbsUp, + text: '点赞', + count: item.wishCount, + status: item.wishListen ?? false, + onPressed: () async { + if (!Accounts.main.isLogin) { + SmartDialog.showToast('请先登录'); + return; + } + final hasLike = item.wishListen ?? false; + final res = await MusicHttp.wishUpdate( + controller.musicId, + hasLike, + ); + if (res.isSuccess) { + if (hasLike) { + item.wishCount--; + } else { + item.wishCount++; + } + item.wishListen = !hasLike; + if (context.mounted) { + (context as Element).markNeedsBuild(); + } + } else { + res.toast(); + } + }, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildArtist(Artist artist, TextStyle? style) { + Widget child = Text('${artist.identity}: ${artist.name}', style: style); + if (!artist.face.isNullOrEmpty) { + child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + NetworkImgLayer( + src: artist.face, + width: 15, + height: 15, + type: ImageType.avatar, + ), + child, + ], + ); + } + child = InkWell( + borderRadius: StyleString.mdRadius, + onTap: artist.mid == null || artist.mid == 0 + ? () => Utils.copyText(artist.name!) + : () => Get.toNamed( + '/member', + parameters: {'mid': artist.mid!.toString()}, + ), + child: child, + ); + return child; + } + + Widget _buildRank( + int? rank, + String name, + TextTheme theme, [ + VoidCallback? onTap, + ]) { + final child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(NumUtil.numFormat(rank), style: theme.bodyLarge), + Text(name, style: theme.bodySmall), + ], + ); + return onTap == null + ? child + : InkWell( + onTap: onTap, + borderRadius: StyleString.mdRadius, + child: Padding(padding: const EdgeInsets.all(4), child: child), + ); + } + + Widget _buildCard(ThemeData theme, MusicDetail item, double maxWidth) { + final textTheme = theme.textTheme; + return SizedBox( + width: maxWidth, + child: Card( + margin: const EdgeInsets.all(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + spacing: 8, + children: [ + GestureDetector( + onTap: () => PageUtils.imageView( + imgList: [SourceModel(url: item.mvCover!)], + ), + child: NetworkImgLayer( + src: item.mvCover, + width: 80, + height: 80, + ), + ), + Expanded( + child: Column( + spacing: 2, + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + borderRadius: StyleString.mdRadius, + // TODO: android intent ACTION_MEDIA_SEARCH + onTap: () => Utils.copyText( + item.musicTitle!, + ), + child: MarqueeText( + item.musicTitle!, + maxWidth: maxWidth - 136, // 80 + 16 + 32 + 8 + style: textTheme.titleMedium, + ), + ), + Wrap( + spacing: 8, + runSpacing: 2, + alignment: WrapAlignment.spaceEvenly, + children: [ + if (!item.artistsList.isNullOrEmpty) + for (var artist in item.artistsList!) + _buildArtist(artist, textTheme.bodySmall), + if (!item.musicPublish.isNullOrEmpty) + Text( + '发行日期:${item.musicPublish}', + style: textTheme.bodySmall, + ), + ], + ), + Wrap( + spacing: 16, + children: [ + if (!item.musicRank.isNullOrEmpty) + PBadge( + text: item.musicRank, + type: PBadgeType.secondary, + isStack: false, + fontSize: 11, + ), + if (item.mvCid != null && item.mvCid != 0) + InkWell( + borderRadius: const BorderRadius.all( + Radius.circular(4), + ), + onTap: () => PageUtils.toVideoPage( + bvid: item.mvBvid, + cid: item.mvCid!, + aid: item.mvAid, + ), + child: ColoredBox( + color: theme.colorScheme.secondaryContainer + .withValues(alpha: 0.5), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 2, + horizontal: 3, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.play_circle_outline, + size: 11, + color: theme + .colorScheme + .onSecondaryContainer, + ), + Text( + '看MV', + style: TextStyle( + color: theme + .colorScheme + .onSecondaryContainer, + height: 1, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + strutStyle: const StrutStyle( + leading: 0, + height: 1, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + SelectableText( + [ + if (!(item.originArtist ?? item.originArtistList) + .isNullOrEmpty) + '原唱:${item.originArtist ?? item.originArtistList}', + if (!item.album.isNullOrEmpty) '专辑:${item.album}', + if (!item.musicSource.isNullOrEmpty) '出处:${item.musicSource}', + ].join('\n'), + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + const Text('热歌榜排名'), + _buildRank(item.hotSongHeat?.lastHeat, '热度', textTheme), + _buildRank(item.listenPv, '总播放量', textTheme), + _buildRank( + item.musicRelation, + '使用稿件量', + textTheme, + () => Get.to( + const MusicRecommandPage(), + arguments: {'id': controller.musicId, 'detail': item}, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget? _buildChart(ThemeData theme, MusicDetail item, double maxWidth) { + final heat = item.hotSongHeat?.songHeat; + if (heat == null || heat.isEmpty) return null; + final colorScheme = theme.colorScheme; + int maxHeat = heat.first.heat; + int minHeat = heat.first.heat; + for (int i = 1; i < heat.length; i++) { + final h = heat[i].heat; + if (h > maxHeat) maxHeat = h; + if (h < minHeat) minHeat = h; + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + spacing: 8, + children: [ + Text('近${heat.length}日热度趋势', style: theme.textTheme.titleMedium), + SizedBox( + width: maxWidth, + height: maxWidth * 0.5, + child: Padding( + padding: const EdgeInsetsGeometry.only(top: 4, right: 22), + child: LineChart( + LineChartData( + lineTouchData: const LineTouchData(enabled: false), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles( + reservedSize: 55, + showTitles: true, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30 * sqrt2, + getTitlesWidget: (index, meta) { + return SideTitleWidget( + angle: -pi / 4, + space: 8 * sqrt2, + meta: meta, + child: Text( + DateUtil.shortFormat.format( + DateTime.fromMillisecondsSinceEpoch( + heat[index.toInt()].date * 1000, + ), + ), + ), + ); + }, + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border.all(color: colorScheme.onSurface), + ), + minX: 0, + maxX: (heat.length - 1).toDouble(), + minY: minHeat.toDouble(), + maxY: maxHeat.toDouble(), + lineBarsData: [ + LineChartBarData( + spots: List.generate( + heat.length, + (index) => FlSpot( + index.toDouble(), + heat[index].heat.toDouble(), + ), + ), + color: colorScheme.primary, + barWidth: 1, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.primary.withValues(alpha: 0.5), + colorScheme.onPrimary.withValues(alpha: 0.5), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/music/widget/music_video_card_h.dart b/lib/pages/music/widget/music_video_card_h.dart new file mode 100644 index 00000000..998a1ac3 --- /dev/null +++ b/lib/pages/music/widget/music_video_card_h.dart @@ -0,0 +1,147 @@ +import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/image/image_save.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/marquee.dart'; +import 'package:PiliPlus/common/widgets/stat/stat.dart'; +import 'package:PiliPlus/http/search.dart'; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models/common/stat_type.dart'; +import 'package:PiliPlus/models_new/music/bgm_recommend_list.dart'; +import 'package:PiliPlus/utils/duration_util.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:flutter/material.dart'; + +class MusicVideoCardH extends StatelessWidget { + final BgmRecommend videoItem; + final Animation animation; + + const MusicVideoCardH({ + super.key, + required this.videoItem, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () async { + int? cid = + videoItem.cid ?? await SearchHttp.ab2c(bvid: videoItem.bvid); + if (cid != null) { + PageUtils.toVideoPage( + bvid: videoItem.bvid, + cid: cid, + cover: videoItem.cover, + title: videoItem.title, + ); + } + }, + onLongPress: () => imageSaveDialog( + title: videoItem.title, + cover: videoItem.cover, + bvid: videoItem.bvid, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, + vertical: 5, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Stack( + clipBehavior: Clip.none, + children: [ + NetworkImgLayer( + src: videoItem.cover, + width: maxWidth, + height: maxHeight, + ), + PBadge( + text: DurationUtil.formatDuration(videoItem.duration), + right: 6.0, + bottom: 6.0, + type: PBadgeType.gray, + ), + ], + ); + }, + ), + ), + const SizedBox(width: 10), + content(context), + ], + ), + ), + ), + ); + } + + Widget content(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + videoItem.title!, + textAlign: TextAlign.start, + style: const TextStyle( + letterSpacing: 0.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Row( + spacing: 8, + children: [ + StatWidget( + type: StatType.play, + value: videoItem.play, + ), + StatWidget( + type: StatType.danmaku, + value: videoItem.danmu, + ), + ], + ), + BounceMarquee( + animation: animation, + child: Row( + spacing: 8, + children: [ + if (videoItem.labelList case final labelList?) + for (final label in labelList) + PBadge( + text: label.name, + isStack: false, + size: PBadgeSize.small, + type: PBadgeType.secondary, + ), + Text( + videoItem.upNickName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index 204e3bbe..bd3e3b14 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -933,11 +933,25 @@ class _UgcIntroPanelState extends TripleState .map( (item) => SearchText( fontSize: 13, - text: item.tagName!, - onTap: (tagName) => Get.toNamed( - '/searchResult', - parameters: {'keyword': tagName}, - ), + text: switch (item.tagType) { + 'bgm' => item.tagName!.replaceFirst('发现', '\u{1f3b5}BGM:'), + 'topic' => '#${item.tagName}', + _ => item.tagName!, + }, + onTap: switch (item.tagType) { + 'bgm' => (_) => Get.toNamed( + '/musicDetail', + parameters: {'musicId': item.musicId!}, + ), + 'topic' => (_) => Get.toNamed( + '/dynTopic', + parameters: {'id': item.tagId!.toString()}, + ), + _ => (tagName) => Get.toNamed( + '/searchResult', + parameters: {'keyword': tagName}, + ), + }, onLongPress: Utils.copyText, ), ) diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index a66e8a51..937ba30e 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; +import 'package:PiliPlus/common/widgets/marquee.dart'; import 'package:PiliPlus/models/common/super_resolution_type.dart'; import 'package:PiliPlus/models/common/video/audio_quality.dart'; import 'package:PiliPlus/models/common/video/cdn_type.dart'; @@ -42,7 +43,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:intl/intl.dart' show DateFormat; -import 'package:marquee/marquee.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:media_kit/media_kit.dart'; import 'package:share_plus/share_plus.dart'; @@ -1960,9 +1960,9 @@ class HeaderControlState extends TripleState { builder: (context, constraints) { return Obx( () { - String title; final videoDetail = introController.videoDetail.value; + final String title; if (videoDetail.videos == 1) { title = videoDetail.title!; } else { @@ -1975,59 +1975,14 @@ class HeaderControlState extends TripleState { ?.pagePart ?? videoDetail.title!; } - final textPainter = TextPainter( - text: TextSpan( - text: title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - ), + return MarqueeText( + title, + maxWidth: constraints.maxWidth, + style: const TextStyle( + color: Colors.white, + fontSize: 16, ), - textDirection: TextDirection.ltr, - maxLines: 1, - )..layout(maxWidth: constraints.maxWidth); - if (textPainter.didExceedMaxLines) { - return ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 25, - ), - child: Marquee( - text: title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - ), - scrollAxis: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.start, - blankSpace: 200, - velocity: 40, - startAfter: const Duration(seconds: 1), - showFadingOnlyWhenScrolling: true, - fadingEdgeStartFraction: 0, - fadingEdgeEndFraction: 0.1, - numberOfRounds: 1, - startPadding: 0, - accelerationDuration: const Duration( - seconds: 1, - ), - accelerationCurve: Curves.linear, - decelerationDuration: const Duration( - milliseconds: 500, - ), - decelerationCurve: Curves.easeOut, - ), - ); - } else { - return Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - ), - maxLines: 1, - textDirection: TextDirection.ltr, - ); - } + ); }, ); }, diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index b7e85818..cdf0ba52 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -36,6 +36,7 @@ import 'package:PiliPlus/pages/msg_feed_top/like_detail/view.dart'; import 'package:PiliPlus/pages/msg_feed_top/like_me/view.dart'; import 'package:PiliPlus/pages/msg_feed_top/reply_me/view.dart'; import 'package:PiliPlus/pages/msg_feed_top/sys_msg/view.dart'; +import 'package:PiliPlus/pages/music/view.dart'; import 'package:PiliPlus/pages/search/view.dart'; import 'package:PiliPlus/pages/search_result/view.dart'; import 'package:PiliPlus/pages/search_trending/view.dart'; @@ -207,6 +208,7 @@ class Routes { page: () => const LiveDmBlockPage(), ), CustomGetPage(name: '/createVote', page: () => const CreateVotePage()), + CustomGetPage(name: '/musicDetail', page: () => const MusicDetailPage()), ]; } diff --git a/lib/utils/app_scheme.dart b/lib/utils/app_scheme.dart index 3c9c44b3..837f9aa2 100644 --- a/lib/utils/app_scheme.dart +++ b/lib/utils/app_scheme.dart @@ -609,9 +609,7 @@ class PiliScheme { launchURL(); } return hasMatch; - } - - if (host.contains('live.bilibili.com')) { + } else if (host.contains('live.bilibili.com')) { String? roomId = uriDigitRegExp.firstMatch(path)?.group(1); if (roomId != null) { PageUtils.toLiveRoom(int.parse(roomId), off: off); @@ -619,9 +617,7 @@ class PiliScheme { } launchURL(); return false; - } - - if (host.contains('space.bilibili.com')) { + } else if (host.contains('space.bilibili.com')) { String? sid = uri.queryParameters['sid'] ?? RegExp(r'lists/(\d+)').firstMatch(path)?.group(1); @@ -636,9 +632,7 @@ class PiliScheme { } launchURL(); return false; - } - - if (host.contains('search.bilibili.com')) { + } else if (host.contains('search.bilibili.com')) { String? keyword = uri.queryParameters['keyword']; if (keyword != null) { PageUtils.toDupNamed( @@ -650,9 +644,23 @@ class PiliScheme { } launchURL(); return false; + } else if (host.contains('music.bilibili.com')) { + // music.bilibili.com/pc/music-detail?music_id=MA*** + // music.bilibili.com/h5-music-detail?music_id=MA*** + if (path.contains('music-detail')) { + final musicId = uri.queryParameters['music_id']; + if (musicId != null && musicId.startsWith('MA')) { + PageUtils.toDupNamed( + '/musicDetail', + parameters: {'musicId': musicId}, + ); + return true; + } + launchURL(); + } } - List pathSegments = uri.pathSegments; + final pathSegments = uri.pathSegments; if (pathSegments.isEmpty) { launchURL(); return false; diff --git a/pubspec.lock b/pubspec.lock index 18955432..89c0578f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1123,14 +1123,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" - marquee: - dependency: "direct main" - description: - name: marquee - sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862 - url: "https://pub.dev" - source: hosted - version: "2.3.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index eed6cb81..3d9828c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -166,7 +166,7 @@ dependencies: #瀑布流 waterfall_flow: ^3.1.0 #跑马灯 - marquee: ^2.3.0 + # marquee: ^2.3.0 #富文本 # extended_text: ^14.1.0 # chat_bottom_container: ^0.2.0