feat: musicDetail (#1157)

* feat: musicDetail

* opt: marquee
This commit is contained in:
My-Responsitories
2025-08-28 17:40:12 +08:00
committed by GitHub
parent 84f7f14a29
commit 08a33d9ce5
22 changed files with 1891 additions and 101 deletions

View File

@@ -38,7 +38,7 @@ typedef IndexedFocusedWidgetBuilder =
typedef IndexedTagStringBuilder = String Function(int index);
class InteractiveviewerGallery<T> extends StatefulWidget {
class InteractiveviewerGallery extends StatefulWidget {
const InteractiveviewerGallery({
super.key,
required this.sources,

View File

@@ -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<StatefulWidget> createState() => _SingleWidgetMarqueeState();
}
class _SingleWidgetMarqueeState extends State<SingleWidgetMarquee>
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<double> 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<RenderBox> {
MarqueeRender({
required Axis direction,
required Animation<double> 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<double> _animation;
Animation<double> get animation => _animation;
set animation(Animation<double> 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);
}
}
}

View File

@@ -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';
}

65
lib/http/music.dart Normal file
View File

@@ -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<LoadingState<MusicDetail>> 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<LoadingState<Null>> 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<LoadingState<List<BgmRecommend>?>> 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']);
}
}
}

View File

@@ -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<Artist>? artists;
final List<Artist>? artistsList;
final int? listenPv;
final List<String>? 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<dynamic>? musicAchievementTimeline;
final FlowAttr? flowAttr;
factory MusicDetail.fromJson(Map<String, dynamic> 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<String, dynamic> 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>? listDetail;
factory Rank.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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>? songHeat;
factory HotSongHeat.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return MusicComment(
state: json["state"],
nums: json["nums"],
oid: json["oid"],
pageType: json["page_type"],
);
}
}

View File

@@ -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>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return LabelList(
name: json["name"],
value: json["value"],
);
}
}

View File

@@ -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<String, dynamic> json) => VideoTagItem(
tagId: json["tag_id"],
tagName: json["tag_name"],
tagType: json["tag_type"],
musicId: json["music_id"],
jumpUrl: json["jump_url"],
);

View File

@@ -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<ReplyInfo>? getDataList(MainListReply response) {
return response.replies;
}
Future<void> onFav() async {
final favorite = stats.value?.favorite;
bool isFav = favorite?.status == true;

View File

@@ -21,7 +21,7 @@ abstract class CommonDynController extends ReplyController<MainListReply>
late final horizontalPreview = Pref.horizontalPreview;
late final List<double> ratio = Pref.dynamicDetailRatio;
double offsetDy = 1;
final double offsetDy = 1;
@override
void onInit() {
@@ -89,4 +89,7 @@ abstract class CommonDynController extends ReplyController<MainListReply>
cursorNext: cursorNext,
offset: paginationReply?.nextOffset,
);
@override
List<ReplyInfo>? getDataList(MainListReply response) => response.replies;
}

View File

@@ -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<ReplyInfo>? getDataList(MainListReply response) {
return response.replies;
}
}

View File

@@ -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<LoadingState<MatchContest?>> infoState =
LoadingState<MatchContest?>.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<ReplyInfo>? getDataList(MainListReply response) {
return response.replies;
}
}

View File

@@ -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<MusicDetail>.loading().obs;
late final String musicId;
bool get showDynActionBar => Pref.showDynActionBar;
@override
void onInit() {
super.onInit();
musicId = Get.parameters['musicId']!;
getMusicDetail();
}
Future<void> 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;
}
}

View File

@@ -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<List<BgmRecommend>?, BgmRecommend> {
late final String musicId;
late final MusicDetail musicDetail;
@override
void onInit() {
super.onInit();
final Map<String, dynamic> args = Get.arguments;
musicId = args['id'];
musicDetail = args['detail'];
queryData();
}
@override
void checkIsEnd(int length) {
isEnd = true;
}
@override
Future<LoadingState<List<BgmRecommend>?>> customGetData() =>
MusicHttp.bgmRecommend(musicId);
}

View File

@@ -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<MusicRecommandPage> createState() => _MusicRecommandPageState();
}
class _MusicRecommandPageState extends State<MusicRecommandPage>
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<List<BgmRecommend>?> 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();
}
}

667
lib/pages/music/view.dart Normal file
View File

@@ -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<MusicDetailPage> createState() => _MusicDetailPageState();
}
class _MusicDetailPageState extends CommonDynPageState<MusicDetailPage> {
@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),
],
),
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -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<double> 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,
),
),
],
),
),
],
),
);
}
}

View File

@@ -933,11 +933,25 @@ class _UgcIntroPanelState extends TripleState<UgcIntroPanel>
.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,
),
)

View File

@@ -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<HeaderControl> {
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<HeaderControl> {
?.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,
);
}
);
},
);
},

View File

@@ -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()),
];
}

View File

@@ -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<String> pathSegments = uri.pathSegments;
final pathSegments = uri.pathSegments;
if (pathSegments.isEmpty) {
launchURL();
return false;

View File

@@ -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:

View File

@@ -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